From ed93963d73a5e46625141df12432d947eb292e19 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 20 Oct 2026 18:45:30 -0700 Subject: [PATCH 1/2] Live Paint: a Live Paint Selection click outside the faces selects the edge it highlights With Select Fills off, or with Alt held, the Live Paint Selection tool highlighted an edge near the pointer even when the pointer was outside every face, but a click there looked only for a face under the pointer and cleared the selection. Outside the faces, the highlight came from the front-most Live Paint group with an edge in reach, not from the nearest edge, and it included hidden groups. The Live Paint Bucket's Shift highlight and Shift-click used the same search. The hover, the Live Paint Selection click and the Bucket's Shift-click now call one function. Outside every face it picks the edge nearest the pointer, within 4 screen pixels, among the visible Live Paint groups. A click selects or strokes the edge that the highlight shows. This change follows up #3052. --- crates/tools/src/builder.rs | 155 ++++++++++++++++++++++++++++-------- 1 file changed, 122 insertions(+), 33 deletions(-) diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 86f6f90db..e8cf0b467 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -602,24 +602,35 @@ enum LpHover { Region(usize), } -fn lp_hover(cx: &ToolContext, cache: &mut RegionCache, p: Point, edge: bool) -> LpHover { - if let Some(g) = live_paint_at(cx.doc, p).and_then(|id| cx.doc.node(id)) { - let part = if edge { edge_near(g, p, cx.tol(4.0)) } else { face_at(g, p) }; - return part.and_then(|n| n.path_data()).map(|pd| LpHover::Part(pd.to_bezpath())).unwrap_or_default(); +/// 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 edge nearest `p` within `tol` (None when there is none). Outside every face, with +/// `edge`, it is the 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 { edge_near(g, p, tol) } else { face_at(g, p) })); } - if edge { - // Edges of every Live Paint group near the pointer. - let mut found = None; - cx.doc.walk(|n| { - if is_live_paint(n) - && let Some(e) = edge_near(n, p, cx.tol(4.0)) - { - found = e.path_data().map(|pd| pd.to_bezpath()); - } - }); - if let Some(bp) = found { - return LpHover::Part(bp); + if !edge { + return None; + } + let mut best: Option<(f64, NodeId, &Arc)> = None; + doc.walk(|n| { + if is_live_paint(n) + && let Some(e) = edge_near(n, p, tol) + && let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) + && best.is_none_or(|b| d <= b.0) + && doc.is_visible(n.id) + { + best = Some((d, n.id, e)); } + }); + best.map(|(_, g, e)| (g, Some(e))) +} + +fn lp_hover(cx: &ToolContext, cache: &mut RegionCache, p: Point, edge: bool) -> LpHover { + if let Some((_, part)) = lp_part_at(cx.doc, p, cx.tol(4.0), edge) { + return part.and_then(|n| n.path_data()).map(|pd| LpHover::Part(pd.to_bezpath())).unwrap_or_default(); } let sel_is_live = cx.selection.objects.iter().any(|id| cx.doc.node(*id).is_some_and(is_live_paint)); if !sel_is_live && let Some(i) = cache.at(cx, p) { @@ -669,16 +680,8 @@ impl Tool for LivePaintBucketTool { self.hover = LpHover::None; let point = json!([p.x, p.y]); if ev.mods.shift { - let mut group = live_paint_at(cx.doc, p); - if group.is_none() { - cx.doc.walk(|n| { - if is_live_paint(n) && edge_near(n, p, cx.tol(4.0)).is_some() { - group = Some(n.id); - } - }); - } - return match group { - Some(g) => { + return match lp_part_at(cx.doc, p, cx.tol(4.0), true) { + Some((g, _)) => { vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))] } None => vec![], @@ -774,13 +777,7 @@ impl Tool for LivePaintSelectionTool { vec![] } PointerKind::Down => { - let mut hit = None; - if let Some(edge) = edge - && let Some(g) = live_paint_at(cx.doc, p).and_then(|id| cx.doc.node(id)) - { - let part = if edge { edge_near(g, p, cx.tol(4.0)) } else { face_at(g, p) }; - hit = part.map(|n| n.id); - } + let hit = edge.and_then(|e| lp_part_at(cx.doc, p, cx.tol(4.0), e)).and_then(|(_, part)| part).map(|n| n.id); match (hit, ev.mods.shift) { (Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], (Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))], @@ -1023,6 +1020,29 @@ mod tests { (d, gid, f2, e) } + /// Adds a Live Paint group of one face `r` with its four sides as edges (left, top, right, + /// bottom) in front of the layer's art; returns the group and the edges. + fn live_square(d: &mut Document, r: Rect) -> (NodeId, [NodeId; 4]) { + let l = d.layers[0].id; + let mut face = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::default()); + face.name = Some(FACE_NAME.into()); + let (a, b, c, e) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)); + let mut kids = vec![Arc::new(face)]; + let mut ids = [NodeId(0); 4]; + for (k, (from, to)) in [(e, a), (a, b), (b, c), (c, e)].into_iter().enumerate() { + ids[k] = d.alloc_id(); + let mut edge = Node::path(ids[k], shapes::line(from, to), Appearance::default()); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + let at = d.layers[0].children().map_or(0, |c| c.len()); + d.insert(Some(l), at, g).unwrap(); + (gid, ids) + } + #[test] fn bucket_click_fills_face_and_shift_click_strokes_edge() { let (d, gid, f2, _e) = live_group_doc(); @@ -1112,6 +1132,75 @@ mod tests { assert!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0).with_mods(shift)).is_empty()); } + #[test] + fn live_paint_selection_click_outside_the_faces_selects_the_edge_it_highlights() { + let mut d = Document::new(500.0, 500.0); + let (_, [_, _, right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let mut t = LivePaintSelectionTool::default(); + // Alt held, and Select Fills off without Alt: 2 pt right of the square, the pointer + // highlights the square's right side, and a click selects that side (Shift-click adds it). + for (fills, m) in [(true, alt), (false, Mods::default())] { + t.set_option("selectFills", &json!(fills)); + t.pointer(&cx, &ev(PointerKind::Move, 102.0, 50.0).with_mods(m)); + let ov = t.overlays(&cx); + let [Overlay::Path { path, .. }] = ov.as_slice() else { panic!("{ov:?}") }; + assert_eq!(path.bounding_box(), Rect::new(100.0, 0.0, 100.0, 100.0)); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(m)); + assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [right.0] }))]); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(Mods { shift: true, ..m })); + assert_eq!(acts, vec![Action::Exec("select.toggle".into(), json!({ "id": right.0 }))]); + } + // Farther than 4 pixels from every edge: no highlight, and a click selects nothing. + t.pointer(&cx, &ev(PointerKind::Move, 106.0, 50.0)); + assert!(t.overlays(&cx).is_empty()); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); + } + + #[test] + fn live_paint_edges_outside_the_faces_come_from_the_nearest_visible_group() { + 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, [front_left, ..]) = live_square(&mut d, Rect::new(104.0, 0.0, 204.0, 100.0)); + let s = Selection::default(); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; + // 1.5 pt from the back group's right side and 2.5 pt from the front group's left side. + let (x, y) = (101.5, 50.0); + let edge_x = |ov: &[Overlay]| match ov { + [] => None, + [Overlay::Path { path, .. }] => Some(path.bounding_box().x0), + _ => panic!("{ov:?}"), + }; + // Live Paint Selection (Alt) and Live Paint Bucket (Shift) highlight and pick the same edge. + let check = |d: &Document, want: Option<(NodeId, NodeId, f64)>| { + let cx = cx(d, &s, &p); + let mut sel = LivePaintSelectionTool::default(); + sel.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(alt)); + assert_eq!(edge_x(&sel.overlays(&cx)), want.map(|w| w.2)); + let picked = match want { + Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })), + None => Action::Exec("select.none".into(), json!({})), + }; + assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, y).with_mods(alt)), vec![picked]); + let mut bucket = LivePaintBucketTool::default(); + bucket.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(shift)); + assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2)); + let stroked = want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, y], "tolerance": 4.0 }))); + assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, x, y).with_mods(shift)), stroked.into_iter().collect::>()); + }; + check(&d, Some((back, back_right, 100.0))); + // A hidden group's edges are not highlighted or picked. + d.node_mut(back).unwrap().visible = false; + check(&d, Some((front, front_left, 104.0))); + d.node_mut(front).unwrap().visible = false; + check(&d, None); + } + #[test] fn live_paint_selection_options_set_the_highlight() { let (d, ..) = live_group_doc(); From 0ec23d90bc368c08c528ad38463d0da52378cdab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 20 Oct 2026 20:56:21 -0700 Subject: [PATCH 2/2] Live Paint: the Live Paint tools skip hidden edges An edge hidden on its own (for example with Object > Hide) while its Live Paint group stays visible could still be highlighted, selected or stroked, and a hidden edge nearer the pointer kept a visible one from being picked. lp_part_at now takes the nearest visible edge (visible_edge_near), inside and outside the faces, for the hover, the Live Paint Selection click and the Bucket's Shift-click. The Bucket's Shift-click sends nothing where no edge is highlighted, and livePaint.strokeEdge also skips hidden edges, so the Bucket strokes the edge it highlights. --- crates/engine/src/cmd/buildcmds.rs | 2 +- crates/engine/src/tests_build.rs | 22 ++++++++++ crates/tools/src/builder.rs | 64 ++++++++++++++++++++++++++---- 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 3c8c5560e..1e65d4d96 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -766,7 +766,7 @@ fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { let tol = f64_or(p, "tolerance", 4.0).clamp(0.0, 1000.0); let edge = s.edit("Live Paint Bucket", |d, _| { 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 = edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; + 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 = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?; e.appearance.set_stroke(paint.clone()); if let Some(st) = e.appearance.stroke_mut() { diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index e7bda4d88..a85abd05e 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -531,6 +531,28 @@ fn live_paint_stroke_edge() { assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [300, 300]})).is_err()); } +#[test] +fn live_paint_stroke_edge_skips_hidden_edges() { + let mut s = session(); + let g = live(&mut s); + // The vertical edge at x=100 is hidden on its own; the group stays visible. + let vertical_at = |s: &Session, x: f64| -> Vec { + vectorcraft_tools::builder::edges(&node(s, g)) + .iter() + .filter(|e| e.geometric_bounds().is_some_and(|bb| (bb.x0 - x).abs() < 1e-6 && (bb.x1 - x).abs() < 1e-6)) + .map(|e| e.id.0) + .collect() + }; + let hidden = vertical_at(&s, 100.0); + assert!(!hidden.is_empty()); + s.execute("object.setProps", &json!({"ids": hidden, "visible": false})).unwrap(); + // 1 pt from the hidden edge, the nearest visible edge within the tolerance is the one at x=50. + let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [99, 50], "tolerance": 60})).unwrap(); + assert!(vertical_at(&s, 50.0).contains(&r["edge"].as_u64().unwrap()), "{r}"); + // With only the hidden edge within the tolerance, no edge is stroked. + assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [99, 50]})).is_err()); +} + #[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 e8cf0b467..48403059d 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -76,8 +76,18 @@ pub fn face_at(group: &Node, p: Point) -> Option<&Arc> { /// The edge of `group` nearest to `p` within `tol`. pub fn edge_near(group: &Node, p: Point, tol: f64) -> Option<&Arc> { + nearest_edge(edges(group), p, tol) +} + +/// The edge of `group` nearest to `p` within `tol` among its edges that are not hidden. +pub fn visible_edge_near(group: &Node, p: Point, tol: f64) -> Option<&Arc> { + nearest_edge(edges(group).into_iter().filter(|e| e.visible), p, tol) +} + +/// The edge of `edges` nearest to `p` within `tol`. +fn nearest_edge<'a>(edges: impl IntoIterator>, p: Point, tol: f64) -> Option<&'a Arc> { let mut best: Option<(f64, &Arc)> = None; - for e in edges(group) { + for e in edges { if let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) && d <= tol && best.is_none_or(|b| d < b.0) @@ -604,12 +614,12 @@ enum LpHover { /// 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 edge nearest `p` within `tol` (None when there is none). Outside every face, with -/// `edge`, it is the 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. +/// `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. 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 { edge_near(g, p, tol) } else { face_at(g, p) })); + return Some((g.id, if edge { visible_edge_near(g, p, tol) } else { face_at(g, p) })); } if !edge { return None; @@ -617,7 +627,7 @@ fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, let mut best: Option<(f64, NodeId, &Arc)> = None; doc.walk(|n| { if is_live_paint(n) - && let Some(e) = edge_near(n, p, tol) + && let Some(e) = visible_edge_near(n, p, tol) && let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) && best.is_none_or(|b| d <= b.0) && doc.is_visible(n.id) @@ -681,10 +691,10 @@ impl Tool for LivePaintBucketTool { let point = json!([p.x, p.y]); if ev.mods.shift { return match lp_part_at(cx.doc, p, cx.tol(4.0), true) { - Some((g, _)) => { + Some((g, Some(_))) => { vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))] } - None => vec![], + _ => vec![], }; } if let Some(g) = live_paint_at(cx.doc, p) { @@ -1201,6 +1211,44 @@ mod tests { check(&d, None); } + #[test] + fn live_paint_tools_skip_hidden_edges() { + let mut d = Document::new(500.0, 500.0); + let (_, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let (front, [front_left, ..]) = live_square(&mut d, Rect::new(104.0, 0.0, 204.0, 100.0)); + // The back group's right side is hidden on its own; both groups stay visible. + d.node_mut(back_right).unwrap().visible = false; + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; + let edge_x = |ov: &[Overlay]| match ov { + [] => None, + [Overlay::Path { path, .. }] => Some(path.bounding_box().x0), + _ => panic!("{ov:?}"), + }; + // Outside the faces, 1.5 pt from the hidden side and 2.5 pt from the front group's left + // side, both tools highlight and pick the visible side. Inside the back face, 1.5 pt from + // the hidden side, no edge is in reach, so neither tool highlights or picks one. + for (x, want) in [(101.5, Some((front, front_left, 104.0))), (98.5, None)] { + let mut sel = LivePaintSelectionTool::default(); + sel.pointer(&cx, &ev(PointerKind::Move, x, 50.0).with_mods(alt)); + assert_eq!(edge_x(&sel.overlays(&cx)), want.map(|w| w.2), "x {x}"); + let picked = match want { + Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })), + None => Action::Exec("select.none".into(), json!({})), + }; + assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, 50.0).with_mods(alt)), vec![picked], "x {x}"); + let mut bucket = LivePaintBucketTool::default(); + bucket.pointer(&cx, &ev(PointerKind::Move, x, 50.0).with_mods(shift)); + assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2), "x {x}"); + let stroked = + want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, 50.0], "tolerance": 4.0 }))); + assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, x, 50.0).with_mods(shift)), stroked.into_iter().collect::>(), "x {x}"); + } + } + #[test] fn live_paint_selection_options_set_the_highlight() { let (d, ..) = live_group_doc();