Merge pull request #3665 from dotCooCoo/feat/shape-builder-stroke-split

Shape Builder: in merge mode, a click on a stroke splits the path there
This commit is contained in:
BFlat
2026-10-23 02:06:32 +07:00
committed by GitHub
34 changed files with 876 additions and 32 deletions
+65 -10
View File
@@ -24,6 +24,7 @@ use vectorcraft_tools::builder::{
};
use vectorcraft_trace as tr;
use super::draw2::replace_with_pieces;
use super::edit::{outermost, selected_roots};
use super::paint::paint_from;
use super::pathops::{Beside, shape_node};
@@ -39,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), 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)",
"{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, 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 goes directly above 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), 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
),
@@ -252,6 +253,8 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
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));
let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, C)?;
let swatches = match p.get("pickColorFrom") {
None | Some(Value::Null) => false,
@@ -267,13 +270,41 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
};
let roots = root_ids(s, p)?;
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
let (ids, merged, touched) = s.edit(label, |d, sel| {
let (map, leaves) = BuilderMap::with_gap(d, &roots, erase, open_filled_as_closed, gap_param(p));
let (ids, merged, touched, split) = s.edit(label, |d, sel| {
let (map, leaves) = BuilderMap::with_gap(d, &roots, erase || split_tol.is_some(), open_filled_as_closed, gap_param(p));
let outlines = b::drawn_outlines(d, &leaves);
let leaves: Vec<Node> = leaves.into_iter().cloned().collect();
if map.shapes.is_empty() {
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
}
let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25);
if let Some(tol) = split_tol
&& let Some(&q) = pts.first()
&& let Some((i, run)) = map.stroke_at(&outlines, q, tol)
&& let (Some(leaf), Some(drawn)) = (leaves.get(i), outlines.get(i))
&& let Some(rest) = po::cut_out(drawn, &run, open_filled_as_closed && !leaf.appearance.fill_paint().is_none(), b::outline_snap(drawn))
&& !rest.is_empty()
{
// The path keeps the rest of it, and a copy of it with the run under the click goes
// directly above it; the other objects stay as they are.
let pieces = [rest, PathData::single(run)].into_iter().filter(|pd| !pd.is_empty()).collect();
let mut replaced = Beside::replacing(d, &[leaf.id]);
let new_ids = replace_with_pieces(d, &mut replaced, leaf.id, pieces)?;
replaced.finish(d)?;
let split = *new_ids.last().ok_or(EngineError::NoNode(leaf.id))?;
// Pick Color From Color Swatches (or `fill`): the stroke takes that paint.
if let Some(f) = &fill
&& let Some(n) = d.node_mut(split)
{
n.appearance.set_stroke(f.clone());
}
// The roots stay selected, with the two paths in place of the path when it was one.
let ids: Vec<NodeId> = roots.iter().flat_map(|r| if *r == leaf.id { new_ids.clone() } else { vec![*r] }).collect();
sel.set(ids.iter().copied());
let mut touched = Touched::default();
touched.add(map.hit(q, Some(tol)));
return Ok((ids, None, touched, Some(split)));
}
let mut touched = Touched::default();
for q in sample_polyline(&pts, step) {
touched.add(map.hit(q, piece_tol));
@@ -283,8 +314,6 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
}
let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect();
// Edges come back from the planar map within a few hundredths of the outlines they follow.
let cut_tol = step * 0.2;
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) };
@@ -309,7 +338,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
} else {
for k in keep {
if let Some((l, _)) = map.lines.get(k) {
new_nodes.push(shape_node(d, l.path.clone(), Some(leaf)));
new_nodes.push(remnant(d, leaf, l.path.clone(), None));
}
}
}
@@ -333,6 +362,9 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
};
let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none();
// Edges come back from the planar map within a few hundredths of the outlines they
// follow, whatever else is selected.
let cut_tol = b::outline_snap(&sh.path);
let mut cut = false;
for piece in &cuts {
if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) {
@@ -343,7 +375,8 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
if !in_union && !cut {
new_nodes.push(d.reid(leaf));
} else if !rest.is_empty() {
new_nodes.push(shape_node(d, rest, Some(leaf)));
// What is left of its own outline keeps its fill rule; united faces fill with non-zero.
new_nodes.push(remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero)));
}
}
if !erase && !union.is_empty() && Some(i) == style_src {
@@ -357,15 +390,19 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
}
let new_ids = replace_roots(d, &roots, new_nodes)?;
sel.set(new_ids.iter().copied());
Ok((new_ids, merged_id, touched))
Ok((new_ids, merged_id, touched, None))
})?;
Ok(json!({
let mut out = json!({
"ids": ids_json(&ids),
"merged": merged.map(|m| m.0),
"regions": touched.regions.len(),
"lines": touched.lines.len(),
"edges": touched.edges.len(),
}))
});
if let Some(e) = split {
out["split"] = json!(e.0);
}
Ok(out)
}
/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids.
@@ -384,6 +421,24 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result
Ok(ids)
}
/// What is left of path or compound path `src`: a copy of it with a fresh id and the geometry `path`
/// (a path for one subpath, else a compound path, as [`shape_node`] makes them). It keeps the rest
/// of `src`: its look, name, opacity mask and attributes, and its fill rule unless `rule` is given.
fn remnant(d: &mut Document, src: &Node, path: PathData, rule: Option<FillRule>) -> Node {
let own = match &src.kind {
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
_ => FillRule::NonZero,
};
let shaped = shape_node(d, path, None);
let mut n = src.clone();
n.id = shaped.id;
n.kind = shaped.kind;
if let NodeKind::Path { rule: r, .. } | NodeKind::Compound { rule: r, .. } = &mut n.kind {
*r = rule.unwrap_or(own);
}
n
}
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, "shapeBuilder.regions")?;
check_numbers(p, &["gap"], "shapeBuilder.regions")?;
+319
View File
@@ -730,6 +730,21 @@ fn shape_builder_erase_click_on_an_edge_opens_its_path() {
assert_eq!(shapes_of(&s, &back), vec![(true, 4, 400.0); 2], "one undo step");
}
/// An even-odd compound path opened by an erase click on one of its edges keeps its fill rule.
#[test]
fn shape_builder_erase_click_on_an_edge_keeps_the_fill_rule() {
let mut s = session();
let c = even_odd_ring(&mut s);
let b = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [c.0, b.0]})).unwrap();
// A's side inside B (1 pt off it).
let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "erase": true})).unwrap();
assert_eq!((r["regions"].as_u64(), r["edges"].as_u64()), (Some(0), Some(1)));
let out = ids(&r);
assert_eq!(shapes_of(&s, &out[..1]), vec![(false, 9, 500.0)], "A open where B crosses it, with H");
assert_eq!(outline(&node(&s, out[0])).1, FillRule::EvenOdd);
}
#[test]
fn shape_builder_erase_drag_along_edges_deletes_each() {
let mut s = session();
@@ -754,6 +769,309 @@ fn shape_builder_erase_drag_into_regions_deletes_them_not_the_edges_it_crosses()
assert_eq!(node(&s, out[0]).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
}
/// In Merge Mode, Clicking Stroke Splits the Path: a click on a lone rectangle's top side, 1 pt
/// outside it, splits the rectangle into that side and the rest. Without the option, the same click
/// is refused as outside every region and leaves the rectangle as it was.
#[test]
fn shape_builder_click_on_a_stroke_splits_its_path() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
set_fill(&mut s, a, Color::rgb(1.0, 0.0, 0.0));
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let undo_before = s.doc().unwrap().history.undo.len();
assert!(s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]]})).is_err());
assert!(s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": false})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
assert_eq!(shapes_of(&s, &[a]), vec![(true, 4, 400.0)]);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap();
let out = ids(&r);
assert_eq!((r["merged"].clone(), r["regions"].as_u64(), r["edges"].as_u64()), (Value::Null, Some(0), Some(1)));
// The rest of the outline (open, the other three sides) comes first, then the top side in
// front of it; both are red.
assert_eq!(shapes_of(&s, &out), vec![(false, 4, 300.0), (false, 2, 100.0)]);
let split = NodeId(r["split"].as_u64().unwrap());
assert_eq!(split, out[1]);
assert_eq!(node(&s, split).geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 0.0));
assert!(out.iter().all(|i| node(&s, *i).appearance.fill_paint() == Paint::solid(Color::rgb(1.0, 0.0, 0.0))));
assert_eq!(s.doc().unwrap().selection.objects, out);
// One undo step puts the rectangle back.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(layer_children(&s), 1);
assert_eq!(shapes_of(&s, &[a]), vec![(true, 4, 400.0)]);
}
/// A click on a stroke that is the whole of its path (a lone line, or a filled open path of one
/// curve) has nothing to split off: with splitStroke it is refused as outside every region, as it
/// is without the option, and the path keeps its geometry and its stroke.
#[test]
fn shape_builder_click_on_a_whole_open_path_does_not_split_it() {
let mut s = session();
let l = line(&mut s, (0.0, 0.0), (100.0, 0.0));
let r = s.execute("path.create", &json!({"anchors": [{"x": 200, "y": 0, "out": [200, -100]}, {"x": 300, "y": 0, "in": [300, -100]}]})).unwrap();
let arch = NodeId(r["id"].as_u64().unwrap());
set_fill(&mut s, arch, Color::rgb(1.0, 0.0, 0.0));
let strokes = |s: &Session| [l, arch].map(|i| node(s, i).appearance.stroke_paint());
let before = (shapes_of(&s, &[l, arch]), strokes(&s));
let undo_before = s.doc().unwrap().history.undo.len();
// The line 0.5 pt off it, then the top of the arch 1 pt above it.
for (id, at) in [(l, [50.0, 0.5]), (arch, [250.0, -76.0])] {
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [at], "splitStroke": true, "fill": "#00ff00"}));
let err = r.err().map(|e| e.to_string()).unwrap_or_default();
assert!(err.contains("no region under the pointer"), "{id:?}: {err}");
}
assert_eq!(layer_children(&s), 2);
assert_eq!((shapes_of(&s, &[l, arch]), strokes(&s)), before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
}
/// The stroke a click splits ends at the points where other paths cross it as well as at the
/// path's anchors: A's right side inside B, then a line piece inside a square. A click away from
/// every stroke merges and an erase click deletes, as they do without the option.
#[test]
fn shape_builder_stroke_split_stops_at_crossings_and_anchors() {
let mut s = session();
let (a, b) = corner(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "splitStroke": true})).unwrap();
let out = ids(&r);
assert_eq!(shapes_of(&s, &out), vec![(false, 5, 350.0), (false, 2, 50.0), (true, 4, 400.0)]);
assert_eq!(node(&s, out[1]).geometric_bounds().unwrap(), Rect::new(100.0, 50.0, 100.0, 100.0));
assert_eq!(node(&s, out[2]).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), "B as it was");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
// A click inside a region away from the strokes merges it; erase mode deletes the edge.
let r = s.execute("shapeBuilder.merge", &json!({"points": [[75, 75]], "splitStroke": true})).unwrap();
assert_eq!((r["regions"].as_u64(), r.get("split")), (Some(1), None));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "erase": true, "splitStroke": true})).unwrap();
assert_eq!(shapes_of(&s, &ids(&r)), vec![(false, 5, 300.0), (true, 4, 400.0)]);
// A click on the line inside the square splits off the piece between the square's sides and
// leaves the square whole.
let mut s = session();
square_and_line(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[51, 50]], "splitStroke": true})).unwrap();
let out = ids(&r);
assert_eq!(r["lines"].as_u64(), Some(1));
let split = NodeId(r["split"].as_u64().unwrap());
assert_eq!(node(&s, split).geometric_bounds().unwrap(), Rect::new(50.0, 0.0, 50.0, 100.0));
let lengths: Vec<f64> = out.iter().map(|i| (outline(&node(&s, *i)).0.length() * 1e6).round() / 1e6).collect();
assert_eq!(lengths, vec![400.0, 40.0, 100.0], "the square, the line's two loose ends, the piece");
}
/// A split replaces only the path it splits: the two paths take its place in the stacking order and
/// in its group, and the other selected objects stay as they are. A path in a Live Paint group does
/// not split.
#[test]
fn shape_builder_stroke_split_leaves_the_other_objects_in_place() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 300.0, 0.0, 100.0, 100.0);
let c = rect(&mut s, 0.0, 200.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0, c.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap();
let split = NodeId(r["split"].as_u64().unwrap());
let order = |s: &Session| -> Vec<NodeId> {
let d = &s.doc().unwrap().doc;
d.children(d.default_layer()).unwrap().iter().map(|n| n.id).collect()
};
let o = order(&s);
assert_eq!((o.len(), o[1], o[2], o[3]), (4, split, b, c), "the rest and the run where A was, under B and C");
assert_eq!(ids(&r), vec![o[0], split, c]);
assert_eq!(s.doc().unwrap().selection.objects, ids(&r));
// In a group, the two paths stay in the group, and the group stays selected.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, c.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap();
assert_eq!(ids(&r), vec![g]);
assert_eq!(node(&s, g).children().unwrap().len(), 3);
assert_eq!(order(&s), vec![b, g]);
// A Live Paint group's edge: the click merges as it does without the option.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let lp = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 1]], "splitStroke": true})).unwrap();
assert_eq!((r.get("split"), r["regions"].as_u64()), (None, Some(1)));
assert!(s.doc().unwrap().doc.node(lp).is_none(), "merged as before");
}
/// A split and an erase click cut a small path with a tolerance from its own size, however far
/// away the other selected objects lie.
#[test]
fn shape_builder_split_and_erase_cut_a_small_path_beside_a_far_object() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 5.0, 5.0, 10.0, 10.0);
let far = rect(&mut s, 1_000_000.0, 0.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0, far.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[5, -0.5]], "splitStroke": true})).unwrap();
let split = NodeId(r["split"].as_u64().unwrap_or_else(|| panic!("no split: {r}")));
assert_eq!(node(&s, split).geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 10.0, 0.0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0, far.0]})).unwrap();
// A's side inside B (0.5 pt off it).
let r = s.execute("shapeBuilder.merge", &json!({"points": [[10.5, 7.5]], "erase": true})).unwrap();
assert_eq!(shapes_of(&s, &ids(&r)[..1]), vec![(false, 5, 30.0)], "A open where B crosses it");
}
/// Pick Color From Color Swatches: the stroke split off takes the current fill as its stroke.
#[test]
fn shape_builder_stroke_split_with_swatches_paints_the_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 before = node(&s, a).appearance.stroke_paint();
s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": []})).unwrap();
let green = s.paint.fill.clone();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true, "pickColorFrom": "swatches"})).unwrap();
let out = ids(&r);
assert_eq!(node(&s, NodeId(r["split"].as_u64().unwrap())).appearance.stroke_paint(), green);
assert_eq!(node(&s, out[0]).appearance.stroke_paint(), before, "the rest keeps its stroke");
}
/// A red even-odd compound path, square A (0..100) with the square H (25..75) inside it, selected.
fn even_odd_ring(s: &mut Session) -> NodeId {
let a = rect(s, 0.0, 0.0, 100.0, 100.0);
let h = rect(s, 25.0, 25.0, 50.0, 50.0);
set_fill(s, a, Color::rgb(1.0, 0.0, 0.0));
s.execute("select.set", &json!({"ids": [a.0, h.0]})).unwrap();
let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("path.setFillRule", &json!({"rule": "evenOdd"})).unwrap();
assert_eq!(outline(&node(s, c)).1, FillRule::EvenOdd);
c
}
/// A path split off an even-odd compound path, and the rest of it, keep its fill rule.
#[test]
fn shape_builder_stroke_split_keeps_the_fill_rule() {
let mut s = session();
even_odd_ring(&mut s);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap();
let out = ids(&r);
assert_eq!(shapes_of(&s, &out), vec![(false, 8, 500.0), (false, 2, 100.0)], "A's other three sides with H, then A's top side");
assert_eq!(out.iter().map(|i| outline(&node(&s, *i)).1).collect::<Vec<_>>(), vec![FillRule::EvenOdd; 2]);
}
/// A split, and an Alt-click that opens a path on one of its edges, keep the path's name and opacity
/// mask on what is left of it.
#[test]
fn shape_builder_stroke_split_and_erase_keep_the_name_and_opacity_mask() {
let mut s = session();
let (a, b) = corner(&mut s);
let m = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
s.execute("transparency.makeOpacityMask", &json!({"ids": [a.0, m.0]})).unwrap();
s.edit("t", |d, _| {
d.node_mut(a).unwrap().name = Some("A".into());
Ok(())
})
.unwrap();
let mask = node(&s, a).extras().mask.clone();
assert!(mask.is_some());
let kept = |s: &Session, ids: &[NodeId]| {
ids.iter().all(|i| {
let n = node(s, *i);
n.name.as_deref() == Some("A") && n.extras().mask == mask
})
};
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap();
let out = ids(&r);
assert_eq!(out.len(), 2);
assert!(kept(&s, &out), "the rest and the run");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "erase": true})).unwrap();
let out = ids(&r);
assert_eq!(shapes_of(&s, &out[..1]), vec![(false, 5, 300.0)]);
assert!(kept(&s, &out[..1]), "A opened where B crosses it");
}
/// The tool with In Merge Mode, Clicking Stroke Splits the Path: over a stroke, the pointer shows
/// scissors and the run a click splits is drawn in the highlight color (none with Highlight Stroke
/// When Editable off), and a click splits it. Off, the same click leaves the rectangle as it was.
#[test]
fn shape_builder_tool_click_on_a_stroke_splits_with_the_option() {
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 v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
let o = s.tool_options();
assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(false), &json!(true), &json!("#ff4f4f")));
let click = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, -1.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, -1.0), v).unwrap();
};
let journal = s.journal.len();
s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, -1.0), v).unwrap();
assert!(s.overlays(v).is_empty());
click(&mut s);
assert_eq!((s.journal.len(), layer_children(&s)), (journal, 1), "off, the click runs no command");
s.set_tool_option_cmd(&json!({"values": {"clickStrokeSplitsPath": true, "highlightColor": "Light Blue"}})).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, -1.0), v).unwrap();
let ov = s.overlays(v);
let [vectorcraft_tools::Overlay::Path { path, color: [0x4f, 0x80, 0xff], .. }] = ov.as_slice() else { panic!("{ov:?}") };
assert_eq!(vectorcraft_geom::Shape::bounding_box(path), Rect::new(0.0, 0.0, 100.0, 0.0));
assert_eq!(s.cursor(vectorcraft_geom::Point::new(50.0, -1.0), Mods::default(), v), vectorcraft_tools::Cursor::Scissors);
s.set_tool_option_cmd(&json!({"values": {"highlightStroke": false}})).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, -1.0), v).unwrap();
assert!(s.overlays(v).is_empty());
click(&mut s);
let (cmd, p) = s.journal.last().unwrap().clone();
assert_eq!((cmd.as_str(), &p["splitStroke"], &p["points"]), ("shapeBuilder.merge", &json!(true), &json!([[50.0, -1.0]])));
assert_eq!(layer_children(&s), 2);
// The options are kept when another tool is chosen.
s.select_tool("selection", v).unwrap();
let o = s.tool_options_of("shapeBuilder");
assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(true), &json!(false), &json!("#4f80ff")));
}
/// The tool with In Merge Mode, Clicking Stroke Splits the Path over a lone line, which a click
/// cannot split: the pointer does not show scissors, the line is not highlighted, and a click runs no
/// command.
#[test]
fn shape_builder_tool_does_not_offer_to_split_a_lone_line() {
let mut s = session();
let l = line(&mut s, (0.0, 0.0), (100.0, 0.0));
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
s.set_tool_option_cmd(&json!({"values": {"clickStrokeSplitsPath": true}})).unwrap();
let journal = s.journal.len();
s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, 1.0), v).unwrap();
assert!(s.overlays(v).is_empty(), "{:?}", s.overlays(v));
assert_eq!(s.cursor(vectorcraft_geom::Point::new(50.0, 1.0), Mods::default(), v), vectorcraft_tools::Cursor::ShapeBuilder);
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 1.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, 1.0), v).unwrap();
assert_eq!((s.journal.len(), layer_children(&s)), (journal, 1), "the click runs no command");
}
/// The tool over a side shorter than the cut tolerance (0.05 pt for a 100 pt path), which
/// `shapeBuilder.merge` cannot cut out: the pointer does not show scissors, the side is not
/// highlighted, and a click outside the path runs no command.
#[test]
fn shape_builder_tool_does_not_offer_to_split_a_side_too_short_to_cut() {
let mut s = session();
let anchors = [(0.0, 0.0), (100.0, 0.0), (100.0, 0.03), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| json!({"x": x, "y": y}));
let r = s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
s.set_tool_option_cmd(&json!({"values": {"clickStrokeSplitsPath": true}})).unwrap();
let journal = s.journal.len();
let at = (101.0, 0.015);
s.pointer(&PointerEvent::new(PointerKind::Move, at.0, at.1), v).unwrap();
assert!(s.overlays(v).is_empty(), "{:?}", s.overlays(v));
assert_eq!(s.cursor(vectorcraft_geom::Point::new(at.0, at.1), Mods::default(), v), vectorcraft_tools::Cursor::ShapeBuilder);
s.pointer(&PointerEvent::new(PointerKind::Down, at.0, at.1), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, at.0, at.1), v).unwrap();
assert_eq!((s.journal.len(), layer_children(&s)), (journal, 1), "the click runs no command");
}
// ---------- Live Paint ----------
fn live(s: &mut Session) -> NodeId {
@@ -843,6 +1161,7 @@ fn shape_builder_refuses_a_number_or_boolean_given_as_text() {
let calls = [
not_a_switch("shapeBuilder.merge", merge(json!({"erase": "true"})), "erase"),
not_a_switch("shapeBuilder.merge", merge(json!({"openFilledAsClosed": "false"})), "openFilledAsClosed"),
not_a_switch("shapeBuilder.merge", merge(json!({"splitStroke": "true"})), "splitStroke"),
not_a_number("shapeBuilder.merge", merge(json!({"erase": true, "tolerance": "2"})), "tolerance"),
not_a_number("shapeBuilder.merge", merge(json!({"gap": "5"})), "gap"),
not_a_switch("shapeBuilder.regions", json!({"openFilledAsClosed": 1}), "openFilledAsClosed"),
+1 -1
View File
@@ -22,7 +22,7 @@ pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
pub use planar::{
BuilderArrangement, SHAPE_BUILDER_MAX_GAP, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point,
live_paint, live_paint_with_gaps, shape_builder, shape_builder_with_gap,
live_paint, live_paint_with_gaps, run_around, shape_builder, shape_builder_with_gap,
};
/// Errors from fallible operations.
+117
View File
@@ -749,6 +749,69 @@ pub fn cut_out(path: &PathData, piece: &SubPath, fill_closes: bool, tol: f64) ->
Some(PathData::new(subpaths))
}
/// The run of `piece` (a Shape Builder edge or line piece) around the point of it nearest `at`,
/// from the nearest of `stops` before that point to the nearest after it, open. Stops are the
/// points within `tol` of the piece, such as the anchors of the path it lies on; an open piece's
/// own ends bound the run where no stop comes first. None when the piece has no segments, or when
/// it is closed and fewer than two different stops lie on it (the run would be all of it).
pub fn run_around(piece: &SubPath, stops: &[Point], at: Point, tol: f64) -> Option<SubPath> {
let n = piece.segment_count();
if n == 0 {
return None;
}
let len = n as f64;
let (u, _) = nearest_u(piece, at)?;
// The stops by x, so that each segment measures only the stops within its bounds.
let mut by_x: Vec<Point> = stops.iter().copied().filter(|s| s.x.is_finite() && s.y.is_finite()).collect();
by_x.sort_by(|a, b| a.x.total_cmp(&b.x));
// The parameters of the stops within `tol` of segment `j` whose nearest point on the whole
// piece lies on it (a ring's segments repeat past either end, `len` apart).
let on = |j: isize| -> Vec<f64> {
use kurbo::ParamCurveNearest;
let seg = piece.segment(j.rem_euclid(n as isize) as usize);
let b = kurbo::Shape::bounding_box(&seg).inflate(tol, tol);
let near = by_x.get(by_x.partition_point(|s| s.x < b.x0)..).unwrap_or_default();
near.iter()
.take_while(|s| s.x <= b.x1)
.filter(|s| s.y >= b.y0 && s.y <= b.y1)
.filter_map(|s| {
let m = seg.nearest(*s, 1e-9);
let d = m.distance_sq.sqrt();
(d <= tol && nearest_u(piece, *s).is_some_and(|(_, nearest)| d <= nearest + 1e-9)).then_some(j as f64 + m.t)
})
.collect()
};
// From the segment under `at`, the walk goes back to the nearest stop at or before it and on
// to the nearest stop after it; an open piece's ends bound it.
let k0 = (u.floor().max(0.0) as isize).min(n as isize - 1);
let (first, last) = if piece.closed { (k0 - n as isize, k0 + n as isize) } else { (0, n as isize - 1) };
let lo = (first..=k0).rev().find_map(|j| on(j).into_iter().filter(|&x| x <= u).max_by(f64::total_cmp));
let lo = match lo {
Some(lo) => lo,
None if !piece.closed => 0.0,
None => return None,
};
let after = u.max(lo + 1e-9);
let hi = (k0..=last).find_map(|j| on(j).into_iter().filter(|&x| x > after).min_by(f64::total_cmp));
let hi = match hi {
Some(hi) => hi,
None if !piece.closed => len,
None => return None,
};
// On a ring, a lone stop would make the run all of it.
if piece.closed && hi - lo >= len - 1e-9 {
return None;
}
// A run that passes the ring's start begins before its end and is unrolled past it.
let (lo, hi) = if lo < 0.0 { (lo + len, hi + len) } else { (lo, hi) };
let run = span(piece, lo, hi)?;
// The run's ends go exactly on the stops they end at.
let stop_at =
|q: Point| stops.iter().copied().filter(|s| s.distance(q) <= tol).min_by(|a, b| a.distance(q).total_cmp(&b.distance(q))).unwrap_or(q);
let ends = [run.anchors.first()?.p, run.anchors.last()?.p].map(stop_at);
Some(snap_ends(run, ends, tol))
}
/// The point of `sp` nearest `p`, as a parameter `segment + t`, with its distance.
fn nearest_u(sp: &SubPath, p: Point) -> Option<(f64, f64)> {
use kurbo::ParamCurveNearest;
@@ -1794,6 +1857,60 @@ mod tests {
assert!((cut.length() - 160.0).abs() < 1e-6, "{}", cut.length());
}
#[test]
fn run_around_stops_at_the_stops_either_side() {
// A lone square's edge is its whole outline; with its corners as stops, a point by any side
// gives that side.
let lone = shape_builder(&[rect(0.0, 0.0, 100.0, 100.0, 0)], true);
let edge = &lone.edges[0].path.subpaths[0];
let corners = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(Point::from);
let side = |at: (f64, f64)| {
run_around(edge, &corners, Point::from(at), 1e-3)
.map(|sp| (sp.closed, PathData::single(sp.clone()).length(), PathData::single(sp).bounds()))
};
assert_eq!(side((50.0, 1.0)), Some((false, 100.0, Some(Rect::new(0.0, 0.0, 100.0, 0.0)))));
for at in [(0.5, 50.0), (99.0, 50.0), (50.0, 100.0)] {
let (closed, length, _) = side(at).expect("a side");
assert!(!closed && (length - 100.0).abs() < 1e-6, "{at:?}: {length}");
}
// Without two stops on it, the run would be all of the outline.
assert_eq!(run_around(edge, &corners[..1], Point::new(50.0, 0.0), 1e-3), None);
// An open piece's run ends at the piece's own ends where no stop comes first; (40, 5) lies
// off the line and does not stop the run.
let line = poly(&[(0.0, 0.0), (100.0, 0.0)], false);
let run = run_around(&line, &[Point::new(40.0, 0.0), Point::new(40.0, 5.0)], Point::new(70.0, 0.0), 1e-3).unwrap();
assert_eq!(ends(&run), (Point::new(40.0, 0.0), Point::new(100.0, 0.0)));
let run = run_around(&line, &[Point::new(40.0, 0.0)], Point::new(10.0, 0.0), 1e-3).unwrap();
assert_eq!(ends(&run), (Point::new(0.0, 0.0), Point::new(40.0, 0.0)));
// A stop counts only where it is nearest the piece: the folded line's last anchor lies
// 0.02 from its first side and does not stop the run there.
let folded = poly(&[(0.0, 0.0), (100.0, 0.0), (50.0, 0.02)], false);
let stops = folded.anchors.iter().map(|a| a.p).collect::<Vec<_>>();
let run = run_around(&folded, &stops, Point::new(25.0, 0.0), 0.05).unwrap();
assert_eq!(ends(&run), (Point::new(0.0, 0.0), Point::new(100.0, 0.0)));
}
#[test]
fn run_around_a_ring_of_4000_sides_measures_only_the_stops_it_reaches() {
// Every anchor of the ring is a stop, so each run is one side. Measuring every stop along
// the whole ring would take 16 million nearest-point searches a call.
let n = 4000;
let pts: Vec<(f64, f64)> = (0..n)
.map(|k| {
let a = k as f64 * std::f64::consts::TAU / n as f64;
(500.0 * a.cos(), 500.0 * a.sin())
})
.collect();
let ring = poly(&pts, true);
let stops: Vec<Point> = ring.anchors.iter().map(|a| a.p).collect();
let start = std::time::Instant::now();
for k in (0..n).step_by(200) {
let run = run_around(&ring, &stops, ring.segment(k).eval(0.5), 1e-3).expect("a side");
assert_eq!(ends(&run), (stops[k], stops[(k + 1) % n]), "side {k}");
}
assert!(start.elapsed().as_secs_f64() < 5.0, "20 runs took {:?}", start.elapsed());
}
/// Proptest shrank a failure of `pieces_cut_out_of_their_inputs` to this input with linesweeper
/// 0.5. The sweep cuts lines 0 and 1 at points of their crossing up to 1.4 `eps` apart, and
/// `vertex_id` merges a point only into a vertex within `eps` of it. Line 0's loose end in the
+204 -7
View File
@@ -16,7 +16,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Paint, Swatch};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Shape as _};
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Shape as _, SubPath};
use vectorcraft_pathops as po;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids};
@@ -156,6 +156,30 @@ pub fn node_outline(n: &Node) -> Option<(PathData, FillRule)> {
}
}
/// How far (pt) a piece of the planar map may lie from `outline`, the outline it follows, to be
/// matched to it or cut out of it: a ten-thousandth of the outline's larger side, at least 0.05.
/// The map's pieces follow the outlines to within a few hundredths of a point.
pub fn outline_snap(outline: &PathData) -> f64 {
outline.bounds().map_or(0.05, |b| (b.width().max(b.height()) / 2000.0).max(0.25) * 0.2)
}
/// The outlines of `leaves` as they are drawn ([`node_outline`]), in their order, for
/// [`BuilderMap::stroke_at`]. A leaf without an outline, and one inside a Live Paint group or a
/// Shaper Group (which make their own paths), gets an empty outline, so no stroke of it splits.
pub fn drawn_outlines(doc: &Document, leaves: &[&Node]) -> Vec<PathData> {
let in_live_group = |id: NodeId| {
let mut up = doc.parent_of(id);
while let Some(p) = up {
if doc.node(p).is_some_and(|n| is_live_paint(n) || n.extras().shaper.is_some()) {
return true;
}
up = doc.parent_of(p);
}
false
};
leaves.iter().map(|l| if in_live_group(l.id) { None } else { node_outline(l) }.map_or_else(PathData::default, |o| o.0)).collect()
}
/// Pathfinder shapes for the given roots (back → front by the order given), keyed by leaf index,
/// with the leaves they came from.
pub fn shapes_for<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec<po::Shape>, Vec<&'a Node>) {
@@ -291,6 +315,40 @@ impl BuilderMap {
piece.or_else(|| self.region_at(p).map(Hit::Region))
}
/// The stroke a click at `p` splits with In Merge Mode, Clicking Stroke Splits the Path: the
/// line piece or edge nearest `p` within `tol` (as [`BuilderMap::hit`] finds it), from the
/// anchor or crossing point before `p` to the one after it on the path it lies on. `outlines`
/// are the inputs' outlines as they are drawn (a filled open path without the edge that closes
/// it in the map). Returns the input's index and the run; None when no piece is that close,
/// when the run is not on the outline as drawn, when it would be a whole closed subpath, when
/// it is too short to cut out (see [`po::cut_out`]), or when it is all of the path (a lone
/// line), which leaves nothing to split off.
pub fn stroke_at(&self, outlines: &[PathData], p: Point, tol: f64) -> Option<(usize, SubPath)> {
use kurbo::ParamCurve as _;
let piece = match self.hit(p, Some(tol))? {
Hit::Line(i) => self.lines.get(i),
Hit::Edge(i) => self.edges.get(i),
Hit::Region(_) => None,
}?;
let i = piece.0.key as usize;
let drawn = outlines.get(i)?;
let stops: Vec<Point> = drawn.anchors().map(|(.., a)| a.p).collect();
let snap = outline_snap(drawn);
let run = po::run_around(piece.0.path.subpaths.first()?, &stops, p, snap)?;
let n = run.segment_count();
if n == 0 {
return None;
}
let mid = run.segment(n / 2).eval(0.5);
drawn.nearest(mid).filter(|n| n.4 <= snap)?;
// A run that cannot be cut out, or that is all of the path (such as a lone line), leaves
// nothing to split off.
if !po::cut_out(drawn, &run, false, snap).is_some_and(|rest| !rest.is_empty()) {
return None;
}
Some((i, run))
}
/// The regions just either side of the middle of `piece` (None: outside every region).
pub fn sides(&self, piece: &po::Shape) -> [Option<usize>; 2] {
let Some(sp) = piece.path.subpaths.first() else { return [None; 2] };
@@ -449,6 +507,8 @@ struct RegionCache {
edges: bool,
/// Gap Detection distance; changing tool options invalidates the cache.
gap: f64,
/// The outlines of the Shape Builder's inputs as they are drawn (see [`BuilderMap::stroke_at`]).
outlines: Vec<PathData>,
}
impl RegionCache {
@@ -466,7 +526,9 @@ impl RegionCache {
let regions = if shapes.is_empty() { vec![] } else { faces_of(po::live_paint(&shapes).0) };
BuilderMap { regions, ..Default::default() }
} else {
BuilderMap::with_gap(cx.doc, &roots, edges, open_filled_as_closed, self.gap).0
let (map, leaves) = BuilderMap::with_gap(cx.doc, &roots, edges, open_filled_as_closed, self.gap);
self.outlines = drawn_outlines(cx.doc, &leaves);
map
};
self.key = Some(key);
self.edges = edges;
@@ -512,8 +574,21 @@ pub struct ShapeBuilderOptions {
pub gap_detection: bool,
/// Gap Length in points (at most [`SHAPE_BUILDER_MAX_GAP`]).
pub gap_length: f64,
/// In Merge Mode, Clicking Stroke Splits the Path (`clickStrokeSplitsPath`, off): in merge mode,
/// a click on a stroke splits its path into the run under the pointer
/// ([`BuilderMap::stroke_at`]) and the rest, and the pointer shows scissors over such a stroke.
pub click_stroke_splits: bool,
/// Highlight Stroke When Editable (`highlightStroke`, on): with `click_stroke_splits`, the run
/// of stroke a click would split is drawn in `stroke_color` while the pointer is over it.
pub highlight_stroke: bool,
/// The color of that highlight (`highlightColor`: `#rrggbb` or a layer color's name; the layer
/// color Red by default).
pub stroke_color: [u8; 3],
}
/// The color Highlight Stroke When Editable draws in by default: the layer color Red.
pub const STROKE_HIGHLIGHT: [u8; 3] = [0xff, 0x4f, 0x4f];
impl Default for ShapeBuilderOptions {
fn default() -> Self {
Self {
@@ -524,6 +599,9 @@ impl Default for ShapeBuilderOptions {
highlight_fill: true,
gap_detection: false,
gap_length: GAP_LENGTHS[0],
click_stroke_splits: false,
highlight_stroke: true,
stroke_color: STROKE_HIGHLIGHT,
}
}
}
@@ -578,16 +656,36 @@ pub struct ShapeBuilderTool {
/// The index in [`color_swatches`] of the swatch the arrow keys picked last.
picked: Option<usize>,
opts: ShapeBuilderOptions,
/// With In Merge Mode, Clicking Stroke Splits the Path: the run of stroke under the pointer,
/// which a click splits off.
stroke: Option<SubPath>,
/// The pointer went more than [`CLICK_TOL_PX`] from the press during the drag, so the gesture
/// is not a click.
far: bool,
}
/// How close (screen px) the pointer must come to a line piece or an edge to delete it.
const LINE_TOL_PX: f64 = 4.0;
/// How far (screen px) the pointer may move between press and release for the gesture to be a
/// click, which with In Merge Mode, Clicking Stroke Splits the Path splits the stroke at the press.
const CLICK_TOL_PX: f64 = 3.0;
impl ShapeBuilderTool {
fn gap(&self) -> f64 {
if self.opts.gap_detection { self.opts.gap_length } else { 0.0 }
}
/// With In Merge Mode, Clicking Stroke Splits the Path and not in erase mode (`alt`): the run of
/// stroke a click at `p` splits off ([`BuilderMap::stroke_at`]).
fn stroke_at(&mut self, cx: &ToolContext, p: Point, alt: bool) -> Option<SubPath> {
if alt || !self.opts.click_stroke_splits {
return None;
}
self.cache.get(cx, true, self.opts.open_filled_as_closed);
self.cache.map.stroke_at(&self.cache.outlines, p, cx.tol(LINE_TOL_PX)).map(|(_, run)| run)
}
fn line_tol(cx: &ToolContext, alt: bool) -> Option<f64> {
alt.then(|| cx.tol(LINE_TOL_PX))
}
@@ -648,7 +746,12 @@ impl Tool for ShapeBuilderTool {
PointerKind::Move => {
self.alt = ev.mods.alt;
self.at = Some(p);
self.hover = self.cache.get(cx, self.alt, self.opts.open_filled_as_closed).hit(p, Self::line_tol(cx, self.alt));
// Over a stroke a click would split, the tool highlights the stroke and not the region.
self.stroke = self.stroke_at(cx, p, self.alt);
self.hover = match self.stroke {
Some(_) => None,
None => self.cache.get(cx, self.alt, self.opts.open_filled_as_closed).hit(p, Self::line_tol(cx, self.alt)),
};
vec![]
}
PointerKind::Down => {
@@ -658,12 +761,16 @@ impl Tool for ShapeBuilderTool {
self.dragging = true;
self.alt = ev.mods.alt;
self.points = vec![p];
self.far = false;
self.touched = Touched::default();
self.touch(cx, None, p);
vec![]
}
PointerKind::Drag if self.dragging => {
self.at = Some(p);
if self.points.first().is_some_and(|s| s.distance(p) > cx.tol(CLICK_TOL_PX)) {
self.far = true;
}
if self.opts.straight_line {
self.straight_to(cx, p);
return vec![];
@@ -685,18 +792,28 @@ impl Tool for ShapeBuilderTool {
self.points.push(p);
}
let points = std::mem::take(&mut self.points);
if std::mem::take(&mut self.touched).is_empty() {
// The mode the drag started in, which its highlight showed.
let erase = self.alt;
// 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));
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![];
}
self.cache.key = None;
self.stroke = None;
let roots = sorted_roots(cx.doc, &cx.selection.objects);
let points = split.map_or(points, |q| vec![q]);
let pts: Vec<Value> = points.iter().map(|q| json!([q.x, q.y])).collect();
// The mode the drag started in, which its highlight showed.
let erase = self.alt;
let mut params = json!({ "ids": json_ids(&roots), "points": pts, "erase": erase, "gap": self.gap() });
if let Some(t) = Self::line_tol(cx, erase) {
params["tolerance"] = json!(t);
}
if split.is_some() {
params["splitStroke"] = json!(true);
params["tolerance"] = json!(cx.tol(LINE_TOL_PX));
}
if !self.opts.open_filled_as_closed {
params["openFilledAsClosed"] = json!(false);
}
@@ -749,13 +866,26 @@ impl Tool for ShapeBuilderTool {
if self.opts.swatches && self.opts.cursor_swatch_preview && !self.dragging {
out.extend(self.swatch_chips(cx));
}
// Highlight Stroke When Editable draws the stroke a click would split.
if let Some(run) = self.stroke.as_ref().filter(|_| self.opts.highlight_stroke && !self.dragging) {
let mut path = BezPath::new();
run.to_bezpath_into(&mut path);
out.push(Overlay::Path { path, color: self.opts.stroke_color, width: 3.0, dashed: false });
}
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor {
// A drag keeps the mode it started in; hovering follows the key.
let erase = if self.dragging { self.alt } else { m.alt };
if erase { Cursor::ShapeBuilderErase } else { Cursor::ShapeBuilder }
if erase {
Cursor::ShapeBuilderErase
} else if self.stroke.is_some() && !self.dragging {
// Over a stroke a click splits.
Cursor::Scissors
} else {
Cursor::ShapeBuilder
}
}
fn busy(&self) -> bool {
@@ -783,6 +913,7 @@ impl Tool for ShapeBuilderTool {
fn options(&self) -> Value {
let o = &self.opts;
let [r, g, b] = o.stroke_color;
json!({
"openFilledAsClosed": o.open_filled_as_closed,
"pickColorFrom": if o.swatches { "swatches" } else { "artwork" },
@@ -791,6 +922,9 @@ impl Tool for ShapeBuilderTool {
"highlightFill": o.highlight_fill,
"gapDetection": o.gap_detection,
"gapLength": o.gap_length,
"clickStrokeSplitsPath": o.click_stroke_splits,
"highlightStroke": o.highlight_stroke,
"highlightColor": format!("#{r:02x}{g:02x}{b:02x}"),
})
}
@@ -808,8 +942,12 @@ impl Tool for ShapeBuilderTool {
o.gap_length = n.clamp(0.0, SHAPE_BUILDER_MAX_GAP);
}
}
"clickStrokeSplitsPath" => o.click_stroke_splits = v.as_bool().unwrap_or(o.click_stroke_splits),
"highlightStroke" => o.highlight_stroke = v.as_bool().unwrap_or(o.highlight_stroke),
"highlightColor" => o.stroke_color = lp_color(v).unwrap_or(o.stroke_color),
_ => {}
}
self.stroke = None;
self.cache.key = None;
self.cache.gap = self.gap();
}
@@ -1218,6 +1356,65 @@ mod tests {
assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 5]);
}
#[test]
fn shape_builder_stroke_options_round_trip_and_pick_the_side_under_the_pointer() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let o = t.options();
assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(false), &json!(true), &json!("#ff4f4f")));
// With the option off, the pointer 1 pt outside A's left side highlights nothing.
t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0));
assert!(t.overlays(&cx).is_empty());
assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), Mods::default()), Cursor::ShapeBuilder);
t.set_option("clickStrokeSplitsPath", &json!(true));
t.set_option("highlightColor", &json!("#00ff00"));
// A value that is not a color or not a switch is ignored.
t.set_option("highlightColor", &json!("nope"));
t.set_option("highlightStroke", &json!("off"));
let o = t.options();
assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(true), &json!(true), &json!("#00ff00")));
// On, the tool draws A's left side from corner to corner in the highlight color and shows the scissors.
t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0));
let ov = t.overlays(&cx);
let [Overlay::Path { path, color: [0, 0xff, 0], width: 3.0, .. }] = ov.as_slice() else { panic!("{ov:?}") };
assert_eq!(path.bounding_box(), Rect::new(0.0, 0.0, 0.0, 100.0));
assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), Mods::default()), Cursor::Scissors);
// With Alt the tool is in erase mode, which deletes edges and does not split them.
let alt = Mods { alt: true, ..Default::default() };
t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0).with_mods(alt));
assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), alt), Cursor::ShapeBuilderErase);
assert!(t.stroke.is_none());
// A click splits; the command gets the point, the switch and the tolerance.
t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0));
let acts = t.pointer(&cx, &ev(PointerKind::Up, -1.0, 25.0));
let [Action::Exec(id, v)] = acts.as_slice() else { panic!("{acts:?}") };
assert_eq!((id.as_str(), &v["splitStroke"], &v["erase"], &v["tolerance"]), ("shapeBuilder.merge", &json!(true), &json!(false), &json!(4.0)));
// A release up to 3 pixels from the press is still a click, which splits where the press was.
t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0));
t.pointer(&cx, &ev(PointerKind::Drag, -1.5, 26.0));
let acts = t.pointer(&cx, &ev(PointerKind::Up, 0.5, 27.0));
let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") };
assert_eq!((&v["splitStroke"], &v["points"]), (&json!(true), &json!([[-1.0, 25.0]])));
// A release farther away is a drag, which splits nothing.
t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0));
assert!(t.pointer(&cx, &ev(PointerKind::Up, -1.0, 30.0)).is_empty());
// So is a Straight Line drag that goes away and ends back near the press.
t.set_option("selection", &json!("straightLine"));
t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0));
t.pointer(&cx, &ev(PointerKind::Drag, 30.0, 25.0));
let acts = t.pointer(&cx, &ev(PointerKind::Up, -0.5, 25.5));
assert!(!acts.iter().any(|a| matches!(a, Action::Exec(_, v) if v.get("splitStroke").is_some())), "{acts:?}");
t.set_option("selection", &json!("freeform"));
// A drag merges as before and sends no switch.
t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0));
t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0));
let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 75.0));
let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") };
assert!(v.get("splitStroke").is_none(), "{v}");
}
#[test]
fn hatch_stays_inside_region() {
let bp = shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)).to_bezpath();
+12 -1
View File
@@ -45,7 +45,18 @@ const ROWS: &[Row] = &[
(
&["shapeBuilder"],
None,
&["openFilledAsClosed", "pickColorFrom", "cursorSwatchPreview", "selection", "highlightFill", "gapDetection", "gapLength"],
&[
"openFilledAsClosed",
"pickColorFrom",
"cursorSwatchPreview",
"selection",
"highlightFill",
"gapDetection",
"gapLength",
"clickStrokeSplitsPath",
"highlightStroke",
"highlightColor",
],
),
(&["artboard"], None, &["moveArt", "scaleArt"]),
// Flare Tool Options (`extra::FLARE_OPTIONS` and the Rays and Rings checkboxes).
@@ -2,12 +2,18 @@
//! which last across tool switches and are saved with the preferences. OK sets them with
//! `tool.setOption {tool: "shapeBuilder", values}`.
//!
//! Fields: `openFilledAsClosed` (Consider Open Filled Paths as Closed), `pickColorFrom`
//! (`artwork` or `swatches`), `cursorSwatchPreview` (with `swatches`), `selection` (`freeform` or
//! `straightLine`) and `highlightFill` (Highlight: Fill).
//! Fields: `gapDetection` and `gapLength` (Gap Detection), `openFilledAsClosed` (Consider Open
//! Filled Paths as Closed), `clickStrokeSplitsPath` (In Merge Mode, Clicking Stroke Splits the
//! Path), `pickColorFrom` (`artwork` or `swatches`), `cursorSwatchPreview` (with `swatches`),
//! `selection` (`freeform` or `straightLine`), `highlightFill` (Highlight: Fill),
//! `highlightStroke` (Highlight: Stroke When Editable) and `highlightColor` (its color: `#rrggbb`,
//! or a layer color's name such as "Red").
use serde_json::{Map, Value, json};
use vectorcraft_color::Color;
use vectorcraft_doc::LAYER_COLORS;
use super::tile_edge_color::resolve;
use super::{DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
@@ -18,7 +24,18 @@ pub const KIND: &str = "shapeBuilderOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 360.0, ..DialogSpec::FORM };
/// The options the dialog edits.
const KEYS: [&str; 7] = ["openFilledAsClosed", "pickColorFrom", "cursorSwatchPreview", "selection", "highlightFill", "gapDetection", "gapLength"];
const KEYS: [&str; 10] = [
"openFilledAsClosed",
"pickColorFrom",
"cursorSwatchPreview",
"selection",
"highlightFill",
"gapDetection",
"gapLength",
"clickStrokeSplitsPath",
"highlightStroke",
"highlightColor",
];
/// Width of the label column.
const LABEL_W: f32 = 110.0;
@@ -58,6 +75,7 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
ui.add_space(8.0);
form::check(ui, d, "openFilledAsClosed", tl!("Consider Open Filled Paths as Closed"));
form::check(ui, d, "clickStrokeSplitsPath", tl!("In Merge Mode, Clicking Stroke Splits the Path"));
ui.add_space(8.0);
let sources = [("artwork", tl!("Artwork")), ("swatches", tl!("Color Swatches"))];
form::choice(ui, d, "pickColorFrom", tl!("Pick Color From:"), (LABEL_W, 160.0), &sources);
@@ -75,7 +93,28 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
}
});
widgets::label_row(ui, tl!("Highlight:"), LABEL_W, |ui| form::check(ui, d, "highlightFill", tl!("Fill")));
widgets::label_row(ui, tl!("Highlight:"), LABEL_W, |ui| {
form::check(ui, d, "highlightFill", tl!("Fill"));
form::check(ui, d, "highlightStroke", tl!("Stroke When Editable"));
});
// The stroke highlight takes a layer color, or any other color from the color button.
let color = resolve(&d.str("highlightColor")).unwrap_or(Color::BLACK);
let [r, g, b, _] = color.to_rgba8(1.0);
let preset = LAYER_COLORS.iter().position(|(_, c)| *c == [r, g, b]);
let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect();
ui.add_enabled_ui(d.bool("highlightStroke"), |ui| {
widgets::label_row(ui, tl!("Color:"), LABEL_W, |ui| {
if let Some((_, [r, g, b])) = widgets::dropdown(ui, "shape-builder-color", names[preset.unwrap_or(LAYER_COLORS.len())], &names, 130.0)
.and_then(|i| LAYER_COLORS.get(i))
{
d.fields.insert("highlightColor".into(), json!(Color::rgb8(*r, *g, *b).to_hex()));
}
let mut rgb = [r, g, b];
if ui.color_edit_button_srgb(&mut rgb).changed() {
d.fields.insert("highlightColor".into(), json!(Color::rgb8(rgb[0], rgb[1], rgb[2]).to_hex()));
}
});
});
false
}
@@ -160,13 +199,58 @@ mod tests {
let o = app.session.tool_options_of("shapeBuilder");
assert_eq!(
o,
json!({"openFilledAsClosed": false, "pickColorFrom": "swatches", "cursorSwatchPreview": true, "selection": "straightLine", "highlightFill": false, "gapDetection": false, "gapLength": 3.0})
json!({
"openFilledAsClosed": false,
"pickColorFrom": "swatches",
"cursorSwatchPreview": true,
"selection": "straightLine",
"highlightFill": false,
"gapDetection": false,
"gapLength": 3.0,
"clickStrokeSplitsPath": false,
"highlightStroke": true,
"highlightColor": "#ff4f4f"
})
);
app.run("tool.options", json!({"tool": "shapeBuilder"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("selection"), d.bool("highlightFill")), ("straightLine".into(), false));
}
#[test]
fn the_stroke_rows_turn_on_stroke_splitting_and_its_highlight_color() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap();
app.run("tool.options", json!({"tool": "shapeBuilder"})).unwrap();
let text = painted(&mut app);
for label in ["In Merge Mode, Clicking Stroke Splits the Path", "Stroke When Editable", "Color:", "Red"] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.bool("clickStrokeSplitsPath"), d.bool("highlightStroke"), d.str("highlightColor")), (false, true, "#ff4f4f".into()));
d.fields.insert("clickStrokeSplitsPath".into(), json!(true));
d.fields.insert("highlightColor".into(), json!("Green"));
super::super::confirm(&mut app).unwrap();
let o = app.session.tool_options_of("shapeBuilder");
assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(true), &json!(true), &json!("#4fff4f")));
// A click on the rectangle's top side highlights it in that color and splits the rectangle
// into the side and the rest.
app.run("select.all", json!({})).unwrap();
app.select_tool("shapeBuilder");
let view = app.view_info();
let ev = |kind| vectorcraft_tools::PointerEvent::new(kind, 200.0, 100.5);
crate::canvas::dispatch(&mut app, &ev(vectorcraft_tools::PointerKind::Move), view);
let ov = app.session.overlays(view);
assert!(ov.iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Path { color: [0x4f, 0xff, 0x4f], .. })), "{ov:?}");
crate::canvas::dispatch(&mut app, &ev(vectorcraft_tools::PointerKind::Down), view);
crate::canvas::dispatch(&mut app, &ev(vectorcraft_tools::PointerKind::Up), view);
let doc = &app.session.doc().unwrap().doc;
let mut bounds: Vec<_> =
doc.layers[0].children().unwrap().iter().filter_map(|n| doc.bounds_of(&[n.id], false)).map(|b| [b.x0, b.y0, b.x1, b.y1]).collect();
bounds.sort_by(|a, b| a[3].total_cmp(&b[3]));
assert_eq!(bounds, vec![[100.0, 100.0, 300.0, 100.0], [100.0, 100.0, 300.0, 200.0]]);
}
#[test]
fn a_straight_line_drag_merges_only_the_regions_the_line_crosses() {
// A wide rectangle and a tall one over the middle of its top edge: three regions, the wide
+2
View File
@@ -5196,12 +5196,14 @@ theme Appearance Външен вид
Keep selected Запазване на избора
Merge only with selection Сливане само с избраното
Consider Open Filled Paths as Closed Отворените запълнени пътища се смятат за затворени
In Merge Mode, Clicking Stroke Splits the Path В режим на сливане щракване върху щриха разделя пътя
Pick Color From: Цвят от:
Artwork Графика
Cursor Swatch Preview Преглед на образеца до курсора
Selection: Селекция:
Straight Line Права линия
Highlight: Открояване:
Stroke When Editable Щрих, когато е редактируем
Sample Color from Screen Взимане на цвят от екрана
The active document changed while the screen color sampler was open Активният документ се промени, докато взимането на цвят от екрана беше отворено
This system has no screen color sampler Тази система няма инструмент за взимане на цвят от екрана
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2284,12 +2284,14 @@ lighting Light Světlo
Keep selected Ponechat vybrané
Merge only with selection Sloučit jen s výběrem
Consider Open Filled Paths as Closed Považovat otevřené vyplněné cesty za uzavřené
In Merge Mode, Clicking Stroke Splits the Path V režimu sloučení rozdělí kliknutí na tah cestu
Pick Color From: Brát barvu z:
Artwork Kresba
Cursor Swatch Preview Náhled vzorku u kurzoru
Selection: Výběr:
Straight Line Přímka
Highlight: Zvýraznění:
Stroke When Editable Tah, je-li upravitelný
The document has no colour swatches. Dokument nemá ve vzorníku žádné barvy.
Style Name: Název stylu:
Crop To Oříznout na
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5140,12 +5140,14 @@ swatch Bright Violet Kraftig violet
@msg no saved selection recalled ingen gemt markering hentet
Merge only with selection Flet kun med markering
Consider Open Filled Paths as Closed Behandl åbne udfyldte kurver som lukkede
In Merge Mode, Clicking Stroke Splits the Path I fletningstilstand opdeler et klik på stregen kurven
Pick Color From: Hent farve fra:
Artwork Grafik
Cursor Swatch Preview Forhåndsvisning af farveprøver ved markøren
Selection: Markering:
Straight Line Lige linje
Highlight: Fremhævning:
Stroke When Editable Streg, når den kan redigeres
Sketch Skitse
Halftone Pattern… Rastermønster…
Halftone Pattern Rastermønster
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1073,6 +1073,7 @@
Keep selected Ausgewählt lassen
Merge only with selection Nur mit Auswahl zusammenfügen
Consider Open Filled Paths as Closed Offene gefüllte Pfade als geschlossen behandeln
In Merge Mode, Clicking Stroke Splits the Path Beim Zusammenfügen teilt ein Klick auf die Kontur den Pfad
Pick Color From: Farbe aufnehmen aus:
Artwork Bildmaterial
Cursor Swatch Preview Farbfeldvorschau am Zeiger
@@ -1080,6 +1081,7 @@
Freeform Freiform
Straight Line Gerade Linie
Highlight: Hervorheben:
Stroke When Editable Kontur, wenn bearbeitbar
Fill Color Flächenfarbe
Fill New Type Objects With Placeholder Text Neue Textobjekte mit Platzhaltertext füllen
Fill Rule Füllregel
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5149,12 +5149,14 @@ effect Points Σημεία
Keep selected Διατήρηση επιλογής
Merge only with selection Συγχώνευση μόνο με την επιλογή
Consider Open Filled Paths as Closed Οι ανοιχτές γεμισμένες διαδρομές θεωρούνται κλειστές
In Merge Mode, Clicking Stroke Splits the Path Σε λειτουργία συγχώνευσης, το κλικ στο περίγραμμα διαιρεί τη διαδρομή
Pick Color From: Λήψη χρώματος από:
Artwork Γραφικά
Cursor Swatch Preview Προεπισκόπηση δείγματος δίπλα στον δείκτη
Selection: Επιλογή:
Straight Line Ευθεία γραμμή
Highlight: Επισήμανση:
Stroke When Editable Περίγραμμα όταν είναι επεξεργάσιμο
Legacy (version 7) Παλαιού τύπου (έκδοση 7)
Legacy .ai Options Επιλογές .ai παλαιού τύπου
Linked Files Συνδεδεμένα αρχεία
Can't render this file because it contains an unexpected character in line 2045 and column 19.
+2
View File
@@ -1096,12 +1096,14 @@
Keep selected Mantener seleccionado
Merge only with selection Combinar solo con la selección
Consider Open Filled Paths as Closed Tratar los trazados abiertos con relleno como cerrados
In Merge Mode, Clicking Stroke Splits the Path En modo de combinación, hacer clic en el trazo divide el trazado
Pick Color From: Tomar el color de:
Artwork Ilustración
Cursor Swatch Preview Vista previa de muestras junto al cursor
Selection: Selección:
Straight Line Línea recta
Highlight: Resaltado:
Stroke When Editable Trazo si es editable
Fill Color Color de relleno
Fill New Type Objects With Placeholder Text Rellenar nuevos objetos de texto con texto de muestra
Fill Rule Regla de relleno
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5161,12 +5161,14 @@ mark-style Japanese Japanilainen
Keep selected Pidä valittuna
Merge only with selection Yhdistä vain valintaan
Consider Open Filled Paths as Closed Käsittele avoimia täytettyjä polkuja suljettuina
In Merge Mode, Clicking Stroke Splits the Path Yhdistämistilassa viivan napsauttaminen jakaa polun
Pick Color From: Värin lähde:
Artwork Kuvitus
Cursor Swatch Preview Värimallien esikatselu osoittimen vieressä
Selection: Valinta:
Straight Line Suora viiva
Highlight: Korostus:
Stroke When Editable Viiva, kun muokattavissa
Legacy (version 7) Vanha (versio 7)
Legacy .ai Options Vanhan .ai-muodon asetukset
Linked Files Linkitetyt tiedostot
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1096,12 +1096,14 @@
Keep selected Garder sélectionné
Merge only with selection Fusionner uniquement avec la sélection
Consider Open Filled Paths as Closed Traiter les tracés ouverts avec fond comme fermés
In Merge Mode, Clicking Stroke Splits the Path En mode fusion, un clic sur le contour scinde le tracé
Pick Color From: Prendre la couleur dans :
Artwork Illustration
Cursor Swatch Preview Aperçu des nuances près du pointeur
Selection: Sélection :
Straight Line Ligne droite
Highlight: Mise en évidence :
Stroke When Editable Contour quand il est modifiable
Fill Color Couleur de fond
Fill New Type Objects With Placeholder Text Remplir les nouveaux objets texte avec un texte de substitution
Fill Rule Règle de remplissage
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1383,12 +1383,14 @@
Keep selected Kijelölés megtartása
Merge only with selection Összeolvasztás csak a kijelöléssel
Consider Open Filled Paths as Closed A nyitott kitöltött útvonalak zártnak számítanak
In Merge Mode, Clicking Stroke Splits the Path Összeolvasztáskor a körvonalra kattintás kettévágja az útvonalat
Pick Color From: Szín forrása:
Artwork Grafika
Cursor Swatch Preview Színminta-előnézet a mutató mellett
Selection: Kijelölés:
Straight Line Egyenes vonal
Highlight: Kiemelés:
Stroke When Editable Körvonal, ha szerkeszthető
Fill New Type Objects With Placeholder Text Új szövegobjektumok kitöltése helykitöltő szöveggel
Fill Rule Kitöltési szabály
Fill with Placeholder Text Kitöltés helykitöltő szöveggel
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5140,12 +5140,14 @@ swatch Bright Violet Violet cerah
@msg no saved selection recalled tidak ada seleksi tersimpan yang dipanggil
Merge only with selection Gabungkan hanya dengan seleksi
Consider Open Filled Paths as Closed Anggap jalur terbuka yang terisi sebagai tertutup
In Merge Mode, Clicking Stroke Splits the Path Dalam mode gabung, mengeklik stroke memisahkan jalur
Pick Color From: Ambil warna dari:
Artwork Karya seni
Cursor Swatch Preview Pratinjau swatch di kursor
Selection: Seleksi:
Straight Line Garis lurus
Highlight: Sorotan:
Stroke When Editable Stroke saat dapat diedit
Sketch Sketsa
Halftone Pattern… Pola halftone…
Halftone Pattern Pola halftone
Can't render this file because it contains an unexpected character in line 3816 and column 19.
+2
View File
@@ -1096,6 +1096,7 @@
Keep selected Mantieni selezionato
Merge only with selection Unisci solo con la selezione
Consider Open Filled Paths as Closed Considera chiusi i tracciati aperti con riempimento
In Merge Mode, Clicking Stroke Splits the Path In modalità fusione, un clic sulla traccia divide il tracciato
Pick Color From: Prendi il colore da:
Artwork Grafica
Cursor Swatch Preview Anteprima dei campioni accanto al cursore
@@ -1103,6 +1104,7 @@
Freeform Libera
Straight Line Linea retta
Highlight: Evidenziazione:
Stroke When Editable Traccia se modificabile
Fill Color Colore riempimento
Fill New Type Objects With Placeholder Text Riempi i nuovi oggetti di testo con testo segnaposto
Fill Rule Regola di riempimento
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1499,6 +1499,7 @@
Keep selected 選択を保持
Merge only with selection 選択範囲とのみ結合
Consider Open Filled Paths as Closed 塗りのある開いたパスを閉じたパスとみなす
In Merge Mode, Clicking Stroke Splits the Path 結合モードで線をクリックするとパスを分割
Pick Color From: 色の取得元:
Artwork アートワーク
Cursor Swatch Preview ポインター横のスウォッチプレビュー
@@ -1506,6 +1507,7 @@
Freeform 自由形状
Straight Line 直線
Highlight: 強調表示:
Stroke When Editable 編集可能な線
Fill: 塗り:
Find 検索
Find Font フォントを検索
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4972,12 +4972,14 @@ imageTrace Outline კონტური
PDF Print PDF (ბეჭდვა)
Merge only with selection შერწყმა მხოლოდ მონიშნულთან
Consider Open Filled Paths as Closed შევსებული ღია ტრაექტორიების შეკრულად მიჩნევა
In Merge Mode, Clicking Stroke Splits the Path შერწყმის რეჟიმში კონტურზე დაწკაპუნება ყოფს ტრაექტორიას
Pick Color From: ფერის წყარო:
Artwork ნახატი
Cursor Swatch Preview ფერის ნიმუშები მაჩვენებელთან
Selection: მონიშვნა:
Straight Line სწორი ხაზი
Highlight: გამოკვეთა:
Stroke When Editable კონტური, როცა რედაქტირებადია
Words Longer Than: სიტყვები, რომელთა სიგრძე აღემატება:
After First: პირველის შემდეგ:
Before Last: ბოლომდე:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1494,6 +1494,7 @@
Keep selected 선택 유지
Merge only with selection 선택 영역과만 병합
Consider Open Filled Paths as Closed 칠이 있는 열린 패스를 닫힌 패스로 처리
In Merge Mode, Clicking Stroke Splits the Path 병합 모드에서 선을 클릭하면 패스 분할
Pick Color From: 색상 출처:
Artwork 아트워크
Cursor Swatch Preview 포인터 옆 견본 미리 보기
@@ -1501,6 +1502,7 @@
Freeform 자유 형태
Straight Line 직선
Highlight: 강조:
Stroke When Editable 편집 가능한 선
Fill: 칠:
Find 찾기
Find Font 글꼴 찾기
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5151,12 +5151,14 @@ swatch Bright Violet Felviolet
@msg no saved selection recalled geen opgeslagen selectie opgeroepen
Merge only with selection Alleen samenvoegen met selectie
Consider Open Filled Paths as Closed Open gevulde paden als gesloten beschouwen
In Merge Mode, Clicking Stroke Splits the Path In samenvoegmodus splitst klikken op de contour het pad
Pick Color From: Kleur kiezen uit:
Artwork Artwork
Cursor Swatch Preview Staalvoorbeeld bij de cursor
Selection: Selectie:
Straight Line Rechte lijn
Highlight: Markering:
Stroke When Editable Contour indien bewerkbaar
Sketch Schets
Halftone Pattern… Rasterpatroon…
Halftone Pattern Rasterpatroon
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2199,12 +2199,14 @@ lighting Light Światło
Keep selected Zachowaj zaznaczenie
Merge only with selection Scalaj tylko z zaznaczeniem
Consider Open Filled Paths as Closed Traktuj otwarte wypełnione ścieżki jako zamknięte
In Merge Mode, Clicking Stroke Splits the Path W trybie scalania kliknięcie obrysu dzieli ścieżkę
Pick Color From: Źródło koloru:
Artwork Grafika
Cursor Swatch Preview Podgląd próbek przy kursorze
Selection: Zaznaczanie:
Straight Line Linia prosta
Highlight: Podświetlenie:
Stroke When Editable Obrys, gdy edytowalny
The document has no colour swatches. Dokument nie zawiera próbek kolorów.
Style Name: Nazwa stylu:
Crop To Kadruj do
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -975,12 +975,14 @@
Keep selected Manter selecionado
Merge only with selection Mesclar somente com a seleção
Consider Open Filled Paths as Closed Tratar caminhos abertos preenchidos como fechados
In Merge Mode, Clicking Stroke Splits the Path No modo de mesclagem, clicar no traçado divide o caminho
Pick Color From: Origem da cor:
Artwork Ilustração
Cursor Swatch Preview Visualização de amostras junto ao cursor
Selection: Seleção:
Straight Line Linha reta
Highlight: Destaque:
Stroke When Editable Traçado quando editável
Fill with Placeholder Text Preencher com Texto de Placeholder
Insert Inline Symbol Inserir Símbolo no Texto
Fill: Preenchimento:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -1094,6 +1094,7 @@
Keep selected Оставлять выбранным
Merge only with selection Объединять только с выделенным
Consider Open Filled Paths as Closed Считать открытые контуры с заливкой замкнутыми
In Merge Mode, Clicking Stroke Splits the Path В режиме объединения щелчок по обводке разделяет контур
Pick Color From: Источник цвета:
Artwork Графика
Cursor Swatch Preview Образцы у указателя
@@ -1101,6 +1102,7 @@
Freeform Произвольно
Straight Line Прямая линия
Highlight: Подсветка:
Stroke When Editable Редактируемая обводка
Fill Color Цвет заливки
Fill New Type Objects With Placeholder Text Заполнять новые текстовые объекты текстом-заполнителем
Fill Rule Правило заливки
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5153,12 +5153,14 @@ theme Appearance Utseende
Keep selected Behåll markerad
Merge only with selection Sammanfoga endast med markeringen
Consider Open Filled Paths as Closed Behandla öppna fyllda banor som slutna
In Merge Mode, Clicking Stroke Splits the Path I sammanfogningsläge delar ett klick på konturen banan
Pick Color From: Hämta färg från:
Artwork Bildmaterial
Cursor Swatch Preview Förhandsvisning av färgprover vid pekaren
Selection: Markering:
Straight Line Rak linje
Highlight: Framhävning:
Stroke When Editable Kontur när den kan redigeras
Legacy (version 7) Äldre (version 7)
Legacy .ai Options Alternativ för äldre .ai
Linked Files Länkade filer
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -5259,6 +5259,7 @@ theme Appearance Görünüm
Diffuse Yayılma
Eyedropper: click the art to sample the key color Damlalık: anahtar rengi örneklemek için çizime tıklayın
Highlight: Vurgu:
In Merge Mode, Clicking Stroke Splits the Path Birleştirme modunda kontura tıklamak yolu böler
Insert Kashidas Keşide Ekle
Load Variable Library… Değişken Kitaplığı Yükle…
Long Uzun
@@ -5270,6 +5271,7 @@ theme Appearance Görünüm
Selection: Seçim:
Standard Standart
Straight Line Düz Çizgi
Stroke When Editable Düzenlenebilir olduğunda kontur
Stylistic Biçimsel
The art keeps its own colors. Çizim kendi renklerini korur.
The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Anahtar renk kontur rengi olur. Diğer renkler aynı miktarda renk tonu kaydırır ve doygunluk ile parlaklıklarını korur.
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -1090,6 +1090,7 @@
Keep selected Залишати вибраним
Merge only with selection Об’єднувати лише з виділеним
Consider Open Filled Paths as Closed Вважати відкриті контури із заливкою замкненими
In Merge Mode, Clicking Stroke Splits the Path У режимі об’єднання клацання по обведенню розділяє контур
Pick Color From: Джерело кольору:
Artwork Графіка
Cursor Swatch Preview Зразки біля вказівника
@@ -1097,6 +1098,7 @@
Freeform Довільно
Straight Line Пряма лінія
Highlight: Підсвічування:
Stroke When Editable Обведення, яке можна редагувати
Fill Color Колір заливки
Fill New Type Objects With Placeholder Text Заповнювати нові текстові об’єкти текстом-заповнювачем
Fill Rule Правило заливки
Can't render this file because it contains an unexpected character in line 733 and column 40.
+2
View File
@@ -1261,6 +1261,7 @@
Keep selected Giữ trạng thái đã chọn
Merge only with selection Chỉ hợp nhất với vùng chọn
Consider Open Filled Paths as Closed Coi đường dẫn mở có tô là đường dẫn đóng
In Merge Mode, Clicking Stroke Splits the Path Ở chế độ hợp nhất, nhấp vào nét sẽ tách đường dẫn
Pick Color From: Lấy màu từ:
Artwork Tác phẩm
Cursor Swatch Preview Xem trước mẫu màu cạnh con trỏ
@@ -1268,6 +1269,7 @@
Freeform Tự do
Straight Line Đường thẳng
Highlight: Làm nổi bật:
Stroke When Editable Nét khi có thể chỉnh sửa
Fill New Type Objects With Placeholder Text Tô đối tượng chữ mới bằng văn bản giữ chỗ
Fill Rule Quy tắc tô
Fill with Placeholder Text Tô bằng văn bản giữ chỗ
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1261
1262
1263
1264
1265
1266
1267
1269
1270
1271
1272
1273
1274
1275
+2
View File
@@ -1037,6 +1037,7 @@
Keep selected 保持选择状态
Merge only with selection 仅与所选对象合并
Consider Open Filled Paths as Closed 将有填色的开放路径视为闭合路径
In Merge Mode, Clicking Stroke Splits the Path 在合并模式下,单击描边可分割路径
Pick Color From: 颜色来源:
Artwork 图稿
Cursor Swatch Preview 在指针旁预览色板
@@ -1044,6 +1045,7 @@
Freeform 自由
Straight Line 直线
Highlight: 突出显示:
Stroke When Editable 可编辑时的描边
Fill Color 填色
Fill New Type Objects With Placeholder Text 用占位文本填充新文字对象
Fill Rule 填充规则
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1061,6 +1061,7 @@
Keep selected 保持選取狀態
Merge only with selection 僅與選取物件合併
Consider Open Filled Paths as Closed 將有填色的開放路徑視為封閉路徑
In Merge Mode, Clicking Stroke Splits the Path 在合併模式下,按一下筆畫可分割路徑
Pick Color From: 顏色來源:
Artwork 圖稿
Cursor Swatch Preview 在指標旁預覽色票
@@ -1068,6 +1069,7 @@
Freeform 自由
Straight Line 直線
Highlight: 強調顯示:
Stroke When Editable 可編輯時的筆畫
Fill New Type Objects With Placeholder Text 以預留位置文字填滿新文字物件
Fill Rule 填色規則
Fill with Placeholder Text 以預留位置文字填滿
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -596,7 +596,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"tool.options",
"Tool Options…",
"",
"{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x and y, draws one there with them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), size (pt); OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields openFilledAsClosed (on), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on); OK runs tool.setOption {tool: \"shapeBuilder\", values})",
"{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x and y, draws one there with them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), size (pt); OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values})",
),
(
"ui.colorGuideOptions",
+17 -5
View File
@@ -1053,8 +1053,8 @@ and `type.fractionalWidths`; `remove` and `overhang` in `type.ruby`; `expandInst
`swatch.delete`; `fromArtwork`, `toGlobal` and `includeTints` in `swatch.newGroup`; `global` and `selection` in
`swatch.addUsedColors`; `visible` in `appearance.setItem`; `copy` in `appearance.transfer`; `reverse` and `append` in
`appearance.copyFrom`; `reverse` and `stroke` in `paint.editGradient`; `stroke` in `paint.setGradientGeom` and in
`paint.freeform.addPoint`, `setPoint`, `deletePoint`, `addLine`, `splitLine` and `get`; `erase` and
`openFilledAsClosed` in `shapeBuilder.merge`; `openFilledAsClosed` in `shapeBuilder.regions`; `closeGaps` in `livePaint.gapOptions`; and
`paint.freeform.addPoint`, `setPoint`, `deletePoint`, `addLine`, `splitLine` and `get`; `erase`,
`openFilledAsClosed` and `splitStroke` in `shapeBuilder.merge`; `openFilledAsClosed` in `shapeBuilder.regions`; `closeGaps` in `livePaint.gapOptions`; and
`enhanced` in `imageTrace.make` and `imageTrace.makeAndExpand`. A null is read as a key left out.
These commands refuse a string or another value that is not a number with `` `radius` must be a number, not "2" ``,
@@ -4568,12 +4568,24 @@ blending mode of the front-most selected object under the drag's first point, el
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`.
`straightLine`: a drag touches what the line from its first point to the pointer crosses), `highlightFill` (on by
default; when it is off, merge mode shades no regions), `gapDetection` (off) with `gapLength` (pt, 0 to 72; 3), `clickStrokeSplitsPath` (In Merge Mode,
Clicking Stroke Splits the Path; off), `highlightStroke` (Highlight Stroke When Editable; on) and `highlightColor`
(`#rrggbb` or a layer color's name; `#ff4f4f`, the layer color Red). With `clickStrokeSplitsPath` on, in merge mode,
the pointer shows scissors within 4 screen pixels of a stroke, `highlightStroke` draws the run of stroke under it in
`highlightColor`, and a click splits that run off its path: the run goes from the anchor or crossing point before the
pointer to the one after it, the path keeps the rest of it, and a copy of the path with the run goes directly above it.
A lone rectangle's side is such a run. A lone line is a run that is all of its path, so the pointer shows no scissors
over it and a click on it does not split it. A release up to 3 screen pixels from the press is a click when the pointer
went no farther during the drag.
With `pickColorFrom` `swatches`, the run takes the current fill as its stroke. The tool sends `openFilledAsClosed`,
`pickColorFrom`, the gap length (`gap`, 0 with Gap Detection off) and, for such a click, `splitStroke: true` with
`shapeBuilder.merge`, which takes them all as params, and `shapeBuilder.regions` takes `openFilledAsClosed` and `gap`.
`shapeBuilder.merge` with `splitStroke` returns the run's id as `split`.
```json
{"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"shapeBuilder","values":{"selection":"straightLine","pickColorFrom":"swatches"}}}}
{"name":"run_command","arguments":{"command":"shapeBuilder.merge","params":{"points":[[50,-1]],"splitStroke":true}}}
```
## Perspective grid
+2 -1
View File
@@ -635,7 +635,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too; Move opens on the offset of the last drag or Move and with the checkboxes it was last confirmed with, so Enter, Enter repeats a move or a copy at the same spacing, as [an Adobe Community thread](https://community.adobe.com/questions-652/move-tool-771221) describes), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, Keep selected, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel.
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.
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`).
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.