mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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")?;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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ỗ
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user