From 0ec23d90bc368c08c528ad38463d0da52378cdab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 20 Oct 2026 20:56:21 -0700 Subject: [PATCH] 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();