From 4bd55029e8d607e7b45fb8822fdfb5d47b00730c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 21 Oct 2026 10:02:02 -0700 Subject: [PATCH] Live Paint: the Live Paint tools and livePaint.fill skip hidden faces A face hidden on its own (for example with Object > Hide after Live Paint Selection picks it) while its Live Paint group stays visible was still highlighted, selected by Live Paint Selection and filled by the Live Paint Bucket, and it kept the face of a group behind it from being picked. live_paint_at and lp_part_at now take the front-most face that is not hidden (visible_face_at). A point on a hidden face falls through to the visible face of a group behind it; where there is none, an edge pick takes the nearest visible edge as it does outside the faces. livePaint.fill also skips hidden faces. With group, a point on a hidden face is an error. With ids, it fills the face there of another Live Paint group among the objects, or else fails and does not merge the group. Docs: docs/mcp.md. --- crates/engine/src/cmd/buildcmds.rs | 25 ++++++++--- crates/engine/src/tests_build.rs | 26 +++++++++++ crates/tools/src/builder.rs | 71 ++++++++++++++++++++++++++---- docs/mcp.md | 3 ++ 4 files changed, 109 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 0c186177f..0d0013404 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -102,7 +102,7 @@ pub fn specs() -> Vec { "Live Paint Bucket", [], None, - "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point → {group, face}", + "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}", has_doc, lp_fill ), @@ -764,13 +764,24 @@ fn lp_fill(s: &mut Session, p: &Value) -> Result { } g } - None => match roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face_at(n, pt).is_some())) { - Some(g) => g, - None => make_live(d, sel, &roots)?.0, - }, + None => { + let live_at = |face: fn(&Node, Point) -> Option<&Arc>| { + 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, + } + } }; - let fid = - d.node(gid).and_then(|n| face_at(n, pt)).map(|f| f.id).ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; + let fid = d + .node(gid) + .and_then(|n| b::visible_face_at(n, pt)) + .map(|f| f.id) + .ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone()); Ok((gid, fid)) })?; diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index 773471f01..f55297312 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -689,6 +689,32 @@ fn live_paint_stroke_edge_skips_hidden_edges() { assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [99, 50]})).is_err()); } +#[test] +fn live_paint_fill_skips_hidden_faces() { + let mut s = session(); + let g = live(&mut s); + // The face from x=50 to x=100 is hidden on its own; the group stays visible. + let hidden = vectorcraft_tools::builder::face_at(&node(&s, g), vectorcraft_geom::Point::new(75.0, 50.0)).unwrap().id; + s.execute("object.setProps", &json!({"ids": [hidden.0], "visible": false})).unwrap(); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let children = node(&s, g).children().unwrap().len(); + // livePaint.fill at a point on the hidden face returns an error and fills no face, with `group` + // and with the group in `ids`, and it does not rebuild the group. + for p in [json!({"group": g.0, "point": [75, 50], "color": "#00ff00"}), json!({"ids": [g.0], "point": [75, 50], "color": "#00ff00"})] { + assert!(s.execute("livePaint.fill", &p).is_err(), "{p}"); + let face = node(&s, hidden); + assert!(!face.visible && face.appearance.fill_paint() != green, "{p}"); + assert_eq!(node(&s, g).children().unwrap().len(), children, "{p}"); + } + // Given another Live Paint group with a face at that point as well, that face is filled. + let other = rect(&mut s, 60.0, 40.0, 20.0, 20.0); + let other = NodeId(s.execute("livePaint.make", &json!({"ids": [other.0]})).unwrap()["id"].as_u64().unwrap()); + let r = s.execute("livePaint.fill", &json!({"ids": [g.0, other.0], "point": [75, 50], "color": "#00ff00"})).unwrap(); + assert_eq!(r["group"], other.0); + assert_eq!(node(&s, NodeId(r["face"].as_u64().unwrap())).appearance.fill_paint(), green); + assert!(!node(&s, hidden).visible); +} + #[test] fn live_paint_release_and_expand() { let mut s = session(); diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 5223bb779..ebc6490e9 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -72,7 +72,17 @@ pub fn path_contains(path: &PathData, rule: FillRule, p: Point) -> bool { /// The face of `group` under `p` (front-most). pub fn face_at(group: &Node, p: Point) -> Option<&Arc> { - faces(group).into_iter().rev().find(|f| f.path_data().is_some_and(|pd| path_contains(pd, FillRule::NonZero, p))) + front_face(faces(group).into_iter(), p) +} + +/// The face of `group` under `p` (front-most) among its faces that are not hidden. +pub fn visible_face_at(group: &Node, p: Point) -> Option<&Arc> { + front_face(faces(group).into_iter().filter(|f| f.visible), p) +} + +/// The face of `faces` (paint order) under `p` (front-most). +fn front_face<'a>(faces: impl DoubleEndedIterator>, p: Point) -> Option<&'a Arc> { + faces.rev().find(|f| f.path_data().is_some_and(|pd| path_contains(pd, FillRule::NonZero, p))) } /// The edge of `group` nearest to `p` within `tol`. @@ -99,11 +109,11 @@ fn nearest_edge<'a>(edges: impl IntoIterator>, p: Point, to best.map(|b| b.1) } -/// The visible Live Paint group with a face under `p` (front-most in paint order). +/// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order). pub fn live_paint_at(doc: &Document, p: Point) -> Option { let mut hit = None; doc.walk(|n| { - if is_live_paint(n) && doc.is_visible(n.id) && face_at(n, p).is_some() { + if is_live_paint(n) && doc.is_visible(n.id) && visible_face_at(n, p).is_some() { hit = Some(n.id); } }); @@ -780,14 +790,14 @@ enum LpHover { Region(usize), } -/// The Live Paint group and part the Live Paint tools hover and click at `p`. Inside a face of a -/// visible Live Paint group ([`live_paint_at`]) it is that group with its face under `p`, or with -/// `edge` its visible edge nearest `p` within `tol` (None when there is none). Outside every face, -/// with `edge`, it is the visible edge nearest `p` within `tol` among the visible Live Paint groups -/// (the front-most group's on a tie). None when no group is found. +/// The Live Paint group and part the Live Paint tools hover and click at `p`. Inside a visible face +/// of a visible Live Paint group ([`live_paint_at`]) it is that group with its visible face under +/// `p`, or with `edge` its visible edge nearest `p` within `tol` (None when there is none). Outside +/// every visible face, with `edge`, it is the visible edge nearest `p` within `tol` among the +/// visible Live Paint groups (the front-most group's on a tie). None when no group is found. fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, Option<&Arc>)> { if let Some(g) = live_paint_at(doc, p).and_then(|id| doc.node(id)) { - return Some((g.id, if edge { visible_edge_near(g, p, tol) } else { face_at(g, p) })); + return Some((g.id, if edge { visible_edge_near(g, p, tol) } else { visible_face_at(g, p) })); } if !edge { return None; @@ -1417,6 +1427,49 @@ mod tests { } } + #[test] + fn live_paint_tools_skip_hidden_faces() { + let mut d = Document::new(500.0, 500.0); + let (back, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let (front, _) = live_square(&mut d, Rect::new(50.0, 0.0, 150.0, 100.0)); + let face = |g: NodeId| faces(d.node(g).unwrap())[0].id; + let (back_face, hidden) = (face(back), face(front)); + // The front group's face is hidden on its own; both groups stay visible. + d.node_mut(hidden).unwrap().visible = false; + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let part = |ov: &[Overlay]| match ov { + [] => None, + [Overlay::Path { path, .. }] => Some(path.bounding_box()), + _ => panic!("{ov:?}"), + }; + // Where the faces overlap, both tools take the back group's face under the hidden one. + let mut bucket = LivePaintBucketTool::default(); + bucket.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); + assert_eq!(part(&bucket.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); + let fill = Action::Exec("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] })); + assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![fill]); + let mut sel = LivePaintSelectionTool::default(); + sel.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); + assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); + assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [back_face.0] }))]); + // Over the hidden face alone, neither tool highlights or picks a face. + bucket.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); + assert_eq!(part(&bucket.overlays(&cx)), None); + assert!(bucket.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)).is_empty()); + sel.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); + assert_eq!(part(&sel.overlays(&cx)), None); + assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); + // Inside the hidden face, 1.5 pt from the back group's right side, Alt-click takes that side + // as it does outside every face. + sel.pointer(&cx, &ev(PointerKind::Move, 101.5, 50.0).with_mods(alt)); + assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(100.0, 0.0, 100.0, 100.0))); + let picked = sel.pointer(&cx, &ev(PointerKind::Down, 101.5, 50.0).with_mods(alt)); + assert_eq!(picked, vec![Action::Exec("select.set".into(), json!({ "ids": [back_right.0] }))]); + } + #[test] fn live_paint_selection_options_set_the_highlight() { let (d, ..) = live_group_doc(); diff --git a/docs/mcp.md b/docs/mcp.md index 4cadd62c2..499c7f803 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1349,6 +1349,9 @@ already walks Image Trace. Short agent entry points: `livePaint.merge` keeps the bottom Live Paint group's id, name, lock, visibility, transparency, appearance, opacity mask and graphic style link. With a Live Paint group among the objects, `livePaint.make` adds the others to it as `livePaint.merge` does, and so does `livePaint.fill {ids}` when `point` is outside that group's faces. + `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. `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