mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 05:24:44 +00:00
Live Paint: an edit that changes nothing records no undo step
A Live Paint Bucket click or drag over faces that already have the fill, a stroke on an edge that already has the paint and width, Gap Options with the options the groups have and no gap to close, and Make or Merge given one Live Paint group alone now leave the document, the undo and redo steps and the saved state as they are, and reply as usual. Such a fill or stroke also leaves the recent colors as they are. Gap Options with Close Gaps With Paths makes a group's faces again only when it adds a path or what paint stops at changes, so the faces keep their ids otherwise.
This commit is contained in:
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Live Paint"],
|
||||
Some("Cmd+Alt+X"),
|
||||
"{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with its front-most covering source's fill; with a Live Paint group among them, add the others to it as livePaint.merge does → {id, faces, edges}",
|
||||
"{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with its front-most covering source's fill; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}",
|
||||
has_selection_or_ids,
|
||||
lp_make
|
||||
),
|
||||
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Merge",
|
||||
["Object", "Live Paint"],
|
||||
None,
|
||||
"{ids?|id?} add the other selected paths (compound paths, groups of paths and Live Paint groups too; type, images and other objects are refused, nothing changes) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link) → {id, faces, edges}",
|
||||
"{ids?|id?} add the other selected paths (compound paths, groups of paths and Live Paint groups too; type, images and other objects are refused, nothing changes) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link); a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}",
|
||||
has_multi_or_ids,
|
||||
lp_merge
|
||||
),
|
||||
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gap Options",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color; a hex color with an alpha part, #rrggbbaa or #rgba, is an error), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}",
|
||||
"{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color; a hex color with an alpha part, #rrggbbaa or #rgba, is an error), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint; when every group already has the options and closeGaps finds no gap, nothing changes (no undo step) → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}",
|
||||
has_selection_or_ids,
|
||||
lp_gap_options
|
||||
),
|
||||
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Bucket",
|
||||
[],
|
||||
None,
|
||||
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}",
|
||||
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces; when every face it reaches already has the paint, nothing changes (no undo step, and the recent colors stay as they are) → {group, face}",
|
||||
has_doc,
|
||||
lp_fill
|
||||
),
|
||||
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Bucket (Stroke)",
|
||||
[],
|
||||
None,
|
||||
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point → {group, edge}",
|
||||
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point; when it already has the paint and the width, nothing changes (no undo step, and the recent colors stay as they are) → {group, edge}",
|
||||
has_doc,
|
||||
lp_stroke_edge
|
||||
),
|
||||
|
||||
@@ -52,8 +52,34 @@ fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<
|
||||
Ok((gid, nf, ne))
|
||||
}
|
||||
|
||||
/// Select `ids` alone, without an undo step, when they are not selected that way already.
|
||||
fn select_only(s: &mut Session, ids: &[NodeId]) -> Result<()> {
|
||||
let st = s.doc()?;
|
||||
let mut sel = st.selection.clone();
|
||||
sel.set(ids.iter().copied());
|
||||
if sel != st.selection {
|
||||
s.select(|_, selection| *selection = sel)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make or Merge given one Live Paint group and nothing else has nothing to add to it, so it leaves
|
||||
/// the document as it is, records no undo step and selects the group: → its reply. `None` for any
|
||||
/// other `roots`.
|
||||
fn lone_live_group(s: &mut Session, roots: &[NodeId]) -> Result<Option<Value>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let Some(&g) = roots.first().filter(|g| roots.iter().all(|r| r == *g)) else { return Ok(None) };
|
||||
let Some(n) = d.node(g).filter(|n| is_live_paint(n)) else { return Ok(None) };
|
||||
let reply = json!({ "id": g.0, "faces": b::faces(n).len(), "edges": b::edges(n).len() });
|
||||
select_only(s, &[g])?;
|
||||
Ok(Some(reply))
|
||||
}
|
||||
|
||||
pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = root_ids(s, p)?;
|
||||
if let Some(reply) = lone_live_group(s, &roots)? {
|
||||
return Ok(reply);
|
||||
}
|
||||
let (id, faces, edges) = s.edit("Make Live Paint", |d, sel| make_live(d, sel, &roots))?;
|
||||
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
|
||||
}
|
||||
@@ -130,6 +156,9 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
|
||||
|
||||
pub(super) fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = root_ids(s, p)?;
|
||||
if let Some(reply) = lone_live_group(s, &roots)? {
|
||||
return Ok(reply);
|
||||
}
|
||||
let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| merge_live(d, sel, &roots))?;
|
||||
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
|
||||
}
|
||||
@@ -230,27 +259,58 @@ pub(super) fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let close = switch_or(p, "closeGaps", false, C)?;
|
||||
let targets = live_targets(s, p)?;
|
||||
let with_params = |mut options: GapOptions| {
|
||||
options.detect = detect.unwrap_or(options.detect);
|
||||
options.size = size.unwrap_or(options.size);
|
||||
options.color = color.unwrap_or(options.color);
|
||||
options
|
||||
};
|
||||
// When every group already has these options and Close Gaps With Paths has no gap to close,
|
||||
// nothing changes: no undo step, and the reply is the same.
|
||||
let unchanged = {
|
||||
let d = &s.doc()?.doc;
|
||||
let (mut faces, mut gaps, mut same) = (0, vec![], true);
|
||||
for g in &targets {
|
||||
let old = d.node(*g).ok_or(EngineError::NoNode(*g))?;
|
||||
let options = old.gap_options();
|
||||
if with_params(options) != options {
|
||||
same = false;
|
||||
break;
|
||||
}
|
||||
let open = open_gaps(old, options);
|
||||
if close && !open.is_empty() {
|
||||
same = false;
|
||||
break;
|
||||
}
|
||||
faces += b::faces(old).len();
|
||||
gaps.extend(open);
|
||||
}
|
||||
same.then_some((faces, gaps))
|
||||
};
|
||||
if let Some((faces, gaps)) = unchanged {
|
||||
select_only(s, &targets)?;
|
||||
return Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": 0 }));
|
||||
}
|
||||
let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| {
|
||||
let (mut faces, mut gaps, mut closed) = (0, vec![], 0);
|
||||
let mut changed = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let old = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let mut options = old.gap_options();
|
||||
options.detect = detect.unwrap_or(options.detect);
|
||||
options.size = size.unwrap_or(options.size);
|
||||
options.color = color.unwrap_or(options.color);
|
||||
let options = with_params(old.gap_options());
|
||||
let mut sources = source_nodes(&old);
|
||||
let mut added = 0;
|
||||
if close {
|
||||
// Close Gaps With Paths adds an unpainted line across each gap to the group's paths.
|
||||
for gap in po::find_gaps(&source_shapes(&sources).0, options.size.points()) {
|
||||
let line = PathData::single(SubPath::polyline(&gap, false));
|
||||
sources.push(Arc::new(Node::path(d.alloc_id(), line, Appearance::default())));
|
||||
closed += 1;
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
closed += added;
|
||||
let mut n = old.clone();
|
||||
// The faces change only with what paint stops at, or with new paths.
|
||||
if close || options.max_gap() != old.gap_options().max_gap() {
|
||||
if added > 0 || options.max_gap() != old.gap_options().max_gap() {
|
||||
let (children, _, _) = build_children(d, sources, options.max_gap())?;
|
||||
if let Some(ch) = n.children_mut() {
|
||||
*ch = children;
|
||||
@@ -305,27 +365,9 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
|
||||
let group = object_param(p, "group", C)?;
|
||||
let roots = if group.is_none() { root_ids(s, p)? } else { vec![] };
|
||||
let (g, face) = s.edit("Live Paint Bucket", |d, sel| {
|
||||
let gid = match group {
|
||||
Some(g) => {
|
||||
if !d.node(g).is_some_and(is_live_paint) {
|
||||
return Err(bad(C, format!("{g} is not a Live Paint group")));
|
||||
}
|
||||
g
|
||||
}
|
||||
None => {
|
||||
let live_at = |face: fn(&Node, Point) -> Option<&Arc<Node>>| {
|
||||
roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some()))
|
||||
};
|
||||
match (live_at(b::visible_face_at), live_at(face_at)) {
|
||||
(Some(g), _) => g,
|
||||
// At a point on a hidden face of a Live Paint group among the objects, the command returns an error
|
||||
// and does not merge the objects.
|
||||
(None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())),
|
||||
(None, None) => make_live(d, sel, &roots)?.0,
|
||||
}
|
||||
}
|
||||
};
|
||||
// The faces the click or drag paints in Live Paint group `gid`: the visible face at the point,
|
||||
// and every visible face the drag crosses; → (the face at the point, the faces).
|
||||
let targets_in = |d: &Document, gid: NodeId| -> Result<(NodeId, std::collections::BTreeSet<NodeId>)> {
|
||||
let fid = d
|
||||
.node(gid)
|
||||
.and_then(|n| b::visible_face_at(n, pt))
|
||||
@@ -355,11 +397,58 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
targets.extend(map.hits_along(&chunk, None).regions.iter().filter_map(|i| face_ids.get(*i).copied()));
|
||||
}
|
||||
}
|
||||
for target in targets {
|
||||
d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone());
|
||||
Ok((fid, targets))
|
||||
};
|
||||
// The Live Paint group the fill paints; `None` when the objects are made into one first.
|
||||
let live = {
|
||||
let d = &s.doc()?.doc;
|
||||
match group {
|
||||
Some(g) => {
|
||||
if !d.node(g).is_some_and(is_live_paint) {
|
||||
return Err(bad(C, format!("{g} is not a Live Paint group")));
|
||||
}
|
||||
Some(g)
|
||||
}
|
||||
None => {
|
||||
let live_at = |face: fn(&Node, Point) -> Option<&Arc<Node>>| {
|
||||
roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some()))
|
||||
};
|
||||
match (live_at(b::visible_face_at), live_at(face_at)) {
|
||||
(Some(g), _) => Some(g),
|
||||
// At a point on a hidden face of a Live Paint group among the objects, the command returns an error
|
||||
// and does not merge the objects.
|
||||
(None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((gid, fid))
|
||||
})?;
|
||||
};
|
||||
let (g, face) = match live {
|
||||
Some(gid) => {
|
||||
let (fid, targets) = targets_in(&s.doc()?.doc, gid)?;
|
||||
// Faces that already have the paint stay as they are: when every face has it, nothing
|
||||
// changes and no undo step is recorded.
|
||||
let d = &s.doc()?.doc;
|
||||
let unpainted: Vec<NodeId> = targets.into_iter().filter(|t| d.node(*t).is_none_or(|f| f.appearance.fill_paint() != paint)).collect();
|
||||
if unpainted.is_empty() {
|
||||
return Ok(json!({ "group": gid.0, "face": fid.0 }));
|
||||
}
|
||||
s.edit("Live Paint Bucket", |d, _| {
|
||||
for target in unpainted {
|
||||
d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone());
|
||||
}
|
||||
Ok((gid, fid))
|
||||
})?
|
||||
}
|
||||
None => s.edit("Live Paint Bucket", |d, sel| {
|
||||
let gid = make_live(d, sel, &roots)?.0;
|
||||
let (fid, targets) = targets_in(d, gid)?;
|
||||
for target in targets {
|
||||
d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone());
|
||||
}
|
||||
Ok((gid, fid))
|
||||
})?,
|
||||
};
|
||||
s.remember_paint(&paint);
|
||||
Ok(json!({ "group": g.0, "face": face.0 }))
|
||||
}
|
||||
@@ -375,9 +464,22 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "width out of range"));
|
||||
}
|
||||
let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0);
|
||||
let edge = s.edit("Live Paint Bucket", |d, _| {
|
||||
let (eid, unchanged) = {
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?;
|
||||
let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
|
||||
let e = b::visible_edge_near(n, pt, tol).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
|
||||
// An edge that already has the paint and the width (or has no stroke and gets none) stays as
|
||||
// it is, and no undo step is recorded.
|
||||
let unchanged = match e.appearance.stroke() {
|
||||
Some(st) => st.paint == paint && st.width == width,
|
||||
None => paint.is_none(),
|
||||
};
|
||||
(e.id, unchanged)
|
||||
};
|
||||
if unchanged {
|
||||
return Ok(json!({ "group": g.0, "edge": eid.0 }));
|
||||
}
|
||||
let edge = s.edit("Live Paint Bucket", |d, _| {
|
||||
let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?;
|
||||
e.appearance.set_stroke(paint.clone());
|
||||
if let Some(st) = e.appearance.stroke_mut() {
|
||||
|
||||
@@ -2446,6 +2446,193 @@ fn live_paint_stroke_edge() {
|
||||
assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [300, 300]})).is_err());
|
||||
}
|
||||
|
||||
/// The undo and redo depths, the unsaved state and the revision of the active document.
|
||||
fn history_state(s: &Session) -> (usize, usize, bool, u64) {
|
||||
let st = s.doc().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty(), st.revision)
|
||||
}
|
||||
|
||||
/// Draw a rectangle and undo it, so a redo step is waiting, then mark the document saved with `g`
|
||||
/// alone selected.
|
||||
fn redo_waiting_and_saved(s: &mut Session, g: NodeId) {
|
||||
rect(s, 300.0, 300.0, 10.0, 10.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
}
|
||||
|
||||
/// The active document and its [`history_state`].
|
||||
type Snapshot = (vectorcraft_doc::Document, (usize, usize, bool, u64));
|
||||
|
||||
fn snapshot(s: &Session) -> Snapshot {
|
||||
((*s.doc().unwrap().doc).clone(), history_state(s))
|
||||
}
|
||||
|
||||
/// After a Live Paint edit that changed nothing: the document, the undo and redo steps, the saved
|
||||
/// state and the revision are as `before`, and Redo still brings back the rectangle
|
||||
/// [`redo_waiting_and_saved`] undid. Undoes the rectangle again, marks the document saved and
|
||||
/// takes a new `before`.
|
||||
fn assert_unchanged(s: &mut Session, before: &mut Snapshot, what: &str) {
|
||||
assert!(*s.doc().unwrap().doc == before.0, "{what}: the document is as it was");
|
||||
assert_eq!(history_state(s), before.1, "{what}: no undo step, the redo step kept, still saved");
|
||||
let children = layer_children(s);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(layer_children(s), children + 1, "{what}: Redo brings back the rectangle");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
*before = snapshot(s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_fill_same_paint_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let drag = json!({"group": g.0, "point": [25, 50], "points": [[25, 50], [125, 50]], "color": "#00ff00"});
|
||||
s.execute("livePaint.fill", &drag).unwrap();
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
// A color used since stays the newest recent color and the last color.
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
s.remember_paint_now(&Paint::solid(blue));
|
||||
let recent = (s.recent_colors.clone(), s.last_solid);
|
||||
let mut before = snapshot(&s);
|
||||
let click = s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "color": "#00ff00"})).unwrap();
|
||||
let face = vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(75.0, 50.0)).unwrap().id;
|
||||
assert_eq!(click, json!({"group": g.0, "face": face.0}), "the usual reply");
|
||||
assert_unchanged(&mut s, &mut before, "a click on a face with the paint");
|
||||
s.execute("livePaint.fill", &drag).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "a drag over faces with the paint");
|
||||
// Without a group, the click finds the Live Paint group among the selected objects.
|
||||
s.execute("livePaint.fill", &json!({"point": [125, 50], "color": "#00ff00"})).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "a click in the selected group");
|
||||
assert_eq!((s.recent_colors.clone(), s.last_solid), recent, "the recent colors are as they were");
|
||||
// A drag that reaches one face without the paint is one undo step and remembers the color.
|
||||
let red = Paint::solid(Color::from_hex("#ff0000").unwrap());
|
||||
for x in [25, 75] {
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [x, 50], "color": "#ff0000"})).unwrap();
|
||||
}
|
||||
s.remember_paint_now(&Paint::solid(blue));
|
||||
let depth = s.doc().unwrap().history.undo.len();
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "points": [[25, 50], [125, 50]], "color": "#ff0000"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), depth + 1);
|
||||
assert_eq!(Paint::solid(s.last_solid), red);
|
||||
let gn = node(&s, g);
|
||||
for x in [25.0, 75.0, 125.0] {
|
||||
assert_eq!(vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().appearance.fill_paint(), red, "{x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_bucket_click_with_the_face_paint_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0));
|
||||
let click = |s: &mut Session, x: f64| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, x, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, x, 50.0), v).unwrap();
|
||||
};
|
||||
click(&mut s, 125.0);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
click(&mut s, 125.0);
|
||||
assert_unchanged(&mut s, &mut before, "a Bucket click on a face with the paint");
|
||||
// A drag over faces with the paint changes nothing either.
|
||||
click(&mut s, 25.0);
|
||||
click(&mut s, 75.0);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 125.0, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "a Bucket drag over faces with the paint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_stroke_edge_same_paint_and_width_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let call = json!({"group": g.0, "point": [100, 50], "color": "#ff0000", "width": 3});
|
||||
let first = s.execute("livePaint.strokeEdge", &call).unwrap();
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
s.remember_paint_now(&Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||||
let last = s.last_solid;
|
||||
let mut before = snapshot(&s);
|
||||
assert_eq!(s.execute("livePaint.strokeEdge", &call).unwrap(), first, "the usual reply");
|
||||
assert_unchanged(&mut s, &mut before, "an edge with the paint and the width");
|
||||
assert_eq!(s.last_solid, last, "the last color is as it was");
|
||||
// Another width is an edit.
|
||||
s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100, 50], "color": "#ff0000", "width": 2})).unwrap();
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_gap_options_same_values_are_no_op() {
|
||||
let mut s = session();
|
||||
let g = live_square_with_a_gap(&mut s);
|
||||
let set = json!({"detect": true, "size": "small", "color": "#123456"});
|
||||
let first = s.execute("livePaint.gapOptions", &set).unwrap();
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
assert_eq!(s.execute("livePaint.gapOptions", &set).unwrap(), first, "the usual reply");
|
||||
assert_unchanged(&mut s, &mut before, "the options the group has");
|
||||
s.execute("livePaint.gapOptions", &json!({})).unwrap();
|
||||
assert_unchanged(&mut s, &mut before, "no options given");
|
||||
// Close Gaps With Paths with a gap to close is an edit.
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"closeGaps": true})).unwrap();
|
||||
assert_eq!(r["closed"], 1);
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_gap_options_close_without_gaps_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"id": g.0, "closeGaps": true})).unwrap();
|
||||
assert_eq!((ids(&r), &r["faces"], &r["closed"], gaps_of(&r).len()), (vec![g], &json!(3), &json!(0), 0));
|
||||
assert_unchanged(&mut s, &mut before, "no gap to close");
|
||||
// With other options, the faces keep their ids when no path is added.
|
||||
let faces = |s: &Session| vectorcraft_tools::builder::faces(&node(s, g)).iter().map(|f| f.id).collect::<Vec<_>>();
|
||||
let ids_before = faces(&s);
|
||||
s.execute("livePaint.gapOptions", &json!({"id": g.0, "closeGaps": true, "color": "#00ff00"})).unwrap();
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
assert_eq!(faces(&s), ids_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_make_existing_group_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
let mut before = snapshot(&s);
|
||||
let r = s.execute("livePaint.make", &json!({})).unwrap();
|
||||
assert_eq!((NodeId(r["id"].as_u64().unwrap()), &r["faces"]), (g, &json!(3)));
|
||||
assert_eq!(r["edges"].as_u64().unwrap(), vectorcraft_tools::builder::edges(&node(&s, g)).len() as u64);
|
||||
assert_unchanged(&mut s, &mut before, "Make with the group alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_merge_single_group_is_no_op() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let other = rect(&mut s, 300.0, 0.0, 50.0, 50.0);
|
||||
redo_waiting_and_saved(&mut s, g);
|
||||
// Given by id, the group is selected without an undo step.
|
||||
s.execute("select.set", &json!({"ids": [other.0]})).unwrap();
|
||||
let before = snapshot(&s);
|
||||
let r = s.execute("livePaint.merge", &json!({"ids": [g.0]})).unwrap();
|
||||
assert_eq!((NodeId(r["id"].as_u64().unwrap()), &r["faces"]), (g, &json!(3)));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
assert!(*s.doc().unwrap().doc == before.0);
|
||||
let (undo, redo, dirty, _) = history_state(&s);
|
||||
assert_eq!((undo, redo, dirty), (before.1.0, before.1.1, before.1.2));
|
||||
// A path to add is an edit.
|
||||
s.execute("livePaint.merge", &json!({"ids": [g.0, other.0]})).unwrap();
|
||||
assert_eq!(history_state(&s).0, before.1.0 + 1);
|
||||
}
|
||||
|
||||
/// Shape Builder refuses an on/off option that is not true or false and a tolerance or gap that is
|
||||
/// not a number, given as text or as another value of the wrong kind, naming the key; the document
|
||||
/// and the undo history stay as they were. True, false and null still work.
|
||||
|
||||
@@ -263,6 +263,40 @@ mod tests {
|
||||
assert_eq!((d.str("size"), d.f64("custom", 0.0)), ("large".to_string(), GapSize::LARGE));
|
||||
}
|
||||
|
||||
/// OK on the options the group has, with Preview on or off, closes the dialog and records no
|
||||
/// undo step: the redo step stays and a saved document stays saved.
|
||||
#[test]
|
||||
fn ok_on_unchanged_options_records_no_undo_step() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
let g = app.run("livePaint.make", json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap();
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
app.run("select.set", json!({"ids": [g]})).unwrap();
|
||||
app.session.doc_mut().unwrap().mark_saved();
|
||||
let state = |app: &VectorcraftApp| {
|
||||
let st = app.session.doc().unwrap();
|
||||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||||
};
|
||||
let before = state(&app);
|
||||
for preview in [false, true] {
|
||||
app.run("ui.livePaintGapOptions", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(preview));
|
||||
frame(&mut app);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "preview={preview}: OK closes the dialog");
|
||||
assert_eq!(state(&app), before, "preview={preview}: no undo step");
|
||||
assert_eq!(options(&app, g), GapOptions::default());
|
||||
}
|
||||
// A changed option is one undo step.
|
||||
app.run("ui.livePaintGapOptions", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("detect".into(), json!(true));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(state(&app).0, before.0 + 1);
|
||||
}
|
||||
|
||||
/// A Gap Preview Color with an alpha part is refused and leaves the group's options as they
|
||||
/// were; `#rrggbb` still sets them.
|
||||
#[test]
|
||||
|
||||
@@ -3511,6 +3511,12 @@ already walks Image Trace. Short agent entry points:
|
||||
`livePaint.fill` does not fill a face that is hidden on its own. With `group`, a point on a hidden face is an
|
||||
error. With `ids`, at a point on a hidden face it fills the face there of another Live Paint group among the
|
||||
objects; otherwise it returns an error and does not merge the objects.
|
||||
A Live Paint command that changes nothing records no undo step, keeps the redo steps, leaves a saved document saved
|
||||
and replies as usual: `livePaint.fill` (a click, or a Bucket drag) when every face it reaches already has the paint,
|
||||
`livePaint.strokeEdge` when the edge already has the paint and the width, `livePaint.gapOptions` (and the Gap Options
|
||||
dialog's OK) when every group already has the options and `closeGaps` finds no gap, and `livePaint.make` or
|
||||
`livePaint.merge` given one Live Paint group alone, which they select. Such a fill or stroke also leaves the recent
|
||||
colors and the last color as they are. `livePaint.gapOptions` makes a group's faces again, with new ids, only when it adds a path or when Gap Detection is turned on or off or given a new size while it is on.
|
||||
|
||||
`brush.selectionArt {ids?|id?}` (a query) replies `{art}`: the object that `brush.new` given the same `ids` or `id`,
|
||||
or the same selection, takes as a new scatter or art brush's art and as a new pattern brush's side tile. Several
|
||||
|
||||
@@ -1429,6 +1429,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)).
|
||||
Double-clicking the Live Paint Selection tool opens its Tool Options ([Live Paint Selection tool options](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html)): with Select Fills off, a click selects an edge instead of a face; with Select Strokes off, Alt-click selects a face instead of an edge; Color and Width set the color and width of the highlight on the face or edge under the pointer (red and 4 px by default). The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintSelection"}`).
|
||||
As VectorCraft's own rule, OK in Live Paint Selection Tool Options, Live Paint Bucket Options, Gap Options and Tile Edge Color gives an error for a color with an alpha part (`#rrggbbaa` or `#rgba`) and changes nothing.
|
||||
As VectorCraft's own rule, a Live Paint edit that changes nothing records no undo step, keeps the redo steps and leaves a saved document saved: a Live Paint Bucket click or drag over faces that already have the fill, a stroke on an edge that already has the paint and weight, Gap Options OK with the options the groups have and no gap to close, and Make or Merge with one Live Paint group alone. Such a fill or stroke also leaves the recent colors as they are. Regression tests: `live_paint_fill_same_paint_is_no_op`, `live_paint_bucket_click_with_the_face_paint_is_no_op`, `live_paint_stroke_edge_same_paint_and_width_is_no_op`, `live_paint_gap_options_same_values_are_no_op`, `live_paint_gap_options_close_without_gaps_is_no_op`, `live_paint_make_existing_group_is_no_op` and `live_paint_merge_single_group_is_no_op` in `crates/engine/src/tests/build.rs`, and `ok_on_unchanged_options_records_no_undo_step` in `crates/ui-egui/src/dialogs/live_paint_gaps.rs`.
|
||||
As VectorCraft's own rule, `tool.setOption` for the Live Paint Bucket and the Live Paint Selection tool, chosen or not, checks every value before it sets any: a switch that is not `true` or `false`, a `color` that is not an RGB color or a layer color's name, or that has an alpha part, a `width` that is not a number and an option the tool does not have give an error naming the option, and nothing is set or saved. A `width` outside 1 to 20 px is still set to the nearest end of that range. Saved settings that hold an invalid value still load, and the tool keeps its default for that option. Evidence: `tests::build::{live_paint_tool_options_refuse_invalid_values_and_change_nothing, live_paint_tool_options_take_rgb_colors_and_clamp_the_width, live_paint_tools_load_saved_settings_with_invalid_values}`.
|
||||
As VectorCraft's own rule, `tool.setOption` for the Shape Builder, chosen or not, checks every value the same way: a switch that is not `true` or `false`, a `pickColorFrom` or `selection` that is not one of its names, a `gapLength` that is not a number, a `highlightColor` that is not an RGB color or a layer color's name, or that has an alpha part, and an option the tool does not have give an error naming the option, and nothing is set or saved. A `gapLength` outside 0 to 72 pt is still set to the nearest end of that range. Evidence: `tests::build::{shape_builder_tool_options_refuse_invalid_values_and_change_nothing, shape_builder_tool_options_take_valid_values_and_clamp_the_gap}`.
|
||||
A drag of 3 screen pixels or more with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected.
|
||||
|
||||
Reference in New Issue
Block a user