From ce472a251cf8e739ce2756594600fb8ca030e30f Mon Sep 17 00:00:00 2001 From: Ismael Trejo Date: Fri, 16 Oct 2026 16:40:27 -0700 Subject: [PATCH] fix(doc): thin strokes take a double-click a few pixels away (#646) A 1 px Line or stroke-only path only counted as hit within 2 screen pixels, so a double-click slightly off it cleared the selection and never entered vector edit mode, and Cmd/Ctrl could not bend it. Strokes with nothing filled now reach 5 screen pixels; filled shapes keep the plain tolerance. --- crates/doc/src/geometry.rs | 10 ++++- crates/engine/src/vector_behaviour_tests.rs | 44 +++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 06596ddc..2a7b42a6 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -51,6 +51,10 @@ pub fn is_closed(n: &Node) -> bool { } } +/// How much further than `tol` a stroke with nothing filled reaches: a thin line or stroke-only path +/// is hard to land on with the plain tolerance, so it gets a wider band (5 screen px for the usual 2 px tolerance). +const STROKE_REACH: f64 = 2.5; + /// Whether `pt` hits `n` itself; in `outline` mode (nothing filled on the canvas) a shape only where /// its outline is. fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, outline: bool) -> bool { @@ -62,7 +66,7 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Page { .. } => false, NodeKind::Text(_) | NodeKind::Slice => n.local_rect().inflate(tol_l, tol_l).contains(lp), NodeKind::Line => { - let half = (n.stroke.weight / 2.0).max(tol_l); + let half = (n.stroke.weight / 2.0).max(tol_l * STROKE_REACH); lp.x >= -half && lp.x <= n.width + half && lp.y.abs() <= half } NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { @@ -78,7 +82,9 @@ fn hits_path(n: &Node, path: &BezPath, lp: Point, tol: f64, inside: bool) -> boo if inside && (n.fills.iter().any(|f| f.visible) || !n.has_visible_stroke()) { return true; } - let reach = (n.stroke.max_weight() / 2.0).max(tol); + // Only a stroke with nothing filled gets the wider band: a filled shape's edge keeps the plain one. + let stroke_only = n.has_visible_stroke() && !n.fills.iter().any(|f| f.visible); + let reach = (n.stroke.max_weight() / 2.0).max(if stroke_only { tol * STROKE_REACH } else { tol }); if !path.bounding_box().inflate(reach, reach).contains(lp) { return false; } diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 0c472ecc..8899dc4e 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -133,6 +133,50 @@ fn entering_and_leaving_vector_edit_mode() { assert!(matches!(s.doc().unwrap().node(id).unwrap().kind, NodeKind::Rectangle), "converting is one undo step"); } +#[test] +fn thin_strokes_are_easy_to_enter_and_bend() { + use crate::tools::ToolId; + // A diagonal line from (100, 100) to (200, 50); `off` steps away from its middle, square to it. + let at = |off: f64| (150.0 + off * 50.0 / 125f64.sqrt() / 10.0, 75.0 + off * 100.0 / 125f64.sqrt() / 10.0); + // (layer, screen px off the stroke, in edit mode after a double-click, derivation) + let rows: Vec<(&str, f64, bool, &str)> = vec![ + ("line", 0.0, true, "double-clicking a selected line edits it as a vector"), + ("line", 4.0, true, "a 1 px line reaches a few pixels, so a double-click near it still lands (#646)"), + ("line", 8.0, false, "a double-click well away from it does not"), + ("path", 4.0, true, "a stroke-only path reaches as far as a line does"), + ("path", 8.0, false, "and no further"), + ]; + for (layer, off, after, why) in rows { + let mut s = Session::new(); + let id = if layer == "line" { + s.set_tool(ToolId::Line); + drag(&mut s, (100.0, 100.0), (200.0, 50.0), NONE); + *s.selection().first().unwrap() + } else { + let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[100, 100], [200, 50]], "closed": false}))["id"]).unwrap(); + // Styled like a pen stroke: 1 px, no fill. + ex(&mut s, "node.set", json!({"ids": [id], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}})); + s.set_selection(vec![id]); + id + }; + assert!(!s.doc().unwrap().node(id).unwrap().fills.iter().any(|f| f.visible), "{why}: the {layer} only has a stroke"); + let p = at(off); + click(&mut s, p, NONE); + s.pointer(&ev(PointerKind::Down, p, NONE)).unwrap(); + s.pointer(&ev(PointerKind::DoubleClick, p, NONE)).unwrap(); + s.pointer(&ev(PointerKind::Up, p, NONE)).unwrap(); + assert_eq!(editing(&s).is_some(), after, "{why}"); + if after { + // Cmd/Ctrl then bends it through the pointer. + drag(&mut s, at(0.0), (170.0, 115.0), Mods { cmd: true, ..NONE }); + let w = s.doc().unwrap().world_transform(id); + let n = net(&s, id); + assert!(!n.segments[0].is_straight(), "{why}: Cmd/Ctrl bends it"); + assert!((w * n.cubic(0).unwrap().eval(0.5) - Point::new(170.0, 115.0)).hypot() < 1.0, "{why}: through the pointer"); + } + } +} + #[test] fn the_pen_follows_the_documented_rules() { let mut s = Session::new();