diff --git a/crates/doc/src/board_route.rs b/crates/doc/src/board_route.rs index 1aa45c810..7949674d9 100644 --- a/crates/doc/src/board_route.rs +++ b/crates/doc/src/board_route.rs @@ -28,9 +28,15 @@ impl RouteEnd { /// How far an elbow connector going around the objects it joins keeps from them. const ROOM: f64 = 24.0; -/// The points a connector runs through from `from` to `to`: from the middle of the side of each -/// box facing the other end (or the side it names), straight, in right angles, or (curved) its two -/// ends with the curve's two control points between them. `None` when a point isn't finite. +/// How far outside the border a connector attached to a side starts and ends (#1916): the stroke's +/// round cap never lies on the fill, and several connectors leaving one side run together along the +/// side's normal for this much before they part, as FigJam's do. +pub const END_GAP: f64 = 4.0; + +/// The points a connector runs through from `from` to `to`: from [`END_GAP`] outside the middle of +/// the side of each box facing the other end (or the side it names), straight, in right angles, or +/// (curved) its two ends with the curve's two control points between them. `None` when a point isn't +/// finite. pub fn route_points(from: RouteEnd, to: RouteEnd, routing: Routing) -> Option> { let (p0, s0) = attach(from, to.centre()); let (p1, s1) = attach(to, from.centre()); @@ -169,8 +175,9 @@ fn attach(e: RouteEnd, other: Point) -> (Point, Option) { RouteEnd::Point(p) => (p, None), RouteEnd::Box(b, side) => { let side = named_or_facing(b, side, other); - (side.point(b), Some(side)) + (side.point(b) + side.normal() * END_GAP, Some(side)) } + // Placed by hand: the point is the user's, gap or not. RouteEnd::On(b, side, p) => (p, Some(named_or_facing(b, side, other))), } } diff --git a/crates/doc/src/board_route_tests.rs b/crates/doc/src/board_route_tests.rs new file mode 100644 index 000000000..a1e972cba --- /dev/null +++ b/crates/doc/src/board_route_tests.rs @@ -0,0 +1,49 @@ +//! Connector routes (`board_route`): where an end attached to a side starts (#1916). + +use figcraft_geom::{Point, Rect}; + +use crate::board::{Routing, Side}; +use crate::board_route::{END_GAP, RouteEnd, route_points}; + +fn shape(x: f64, y: f64) -> Rect { + Rect::new(x, y, x + 100.0, y + 60.0) +} + +/// An end attached to a side starts `END_GAP` outside the middle of that side, not on the border, +/// so the stroke never lies on the fill (#1916: FigJam starts the line a few pixels outside). +#[test] +fn an_attached_end_starts_outside_the_border() { + let (a, b) = (shape(0.0, 0.0), shape(300.0, 0.0)); + for routing in [Routing::Straight, Routing::Elbow, Routing::Curved] { + let pts = route_points(RouteEnd::Box(a, Side::Auto), RouteEnd::Box(b, Side::Auto), routing).unwrap(); + assert_eq!(pts.first().copied(), Some(Point::new(100.0 + END_GAP, 30.0)), "{routing:?}: leaves the right side"); + assert_eq!(pts.last().copied(), Some(Point::new(300.0 - END_GAP, 30.0)), "{routing:?}: arrives at the left side"); + } + // A named side, and the gap along its own normal. + let pts = route_points(RouteEnd::Box(a, Side::Bottom), RouteEnd::Point(Point::new(50.0, 200.0)), Routing::Straight).unwrap(); + assert_eq!(pts.first().copied(), Some(Point::new(50.0, 60.0 + END_GAP))); +} + +/// Several connectors leaving one side run together along its normal before they part: an elbow's +/// and a curve's first run leave straight out of the side. +#[test] +fn connectors_from_one_side_leave_together() { + let a = shape(0.0, 0.0); + let (up, down) = (RouteEnd::Box(shape(300.0, -200.0), Side::Auto), RouteEnd::Box(shape(300.0, 200.0), Side::Auto)); + for routing in [Routing::Elbow, Routing::Curved] { + let p = route_points(RouteEnd::Box(a, Side::Right), up, routing).unwrap(); + let q = route_points(RouteEnd::Box(a, Side::Right), down, routing).unwrap(); + assert_eq!(p[0], q[0], "{routing:?}: the same start"); + assert_eq!(p[1].y, p[0].y, "{routing:?}: out along the side's normal first"); + assert_eq!(q[1].y, q[0].y, "{routing:?}: out along the side's normal first"); + } +} + +/// A point placed by hand on the object (`RouteEnd::On`) is the user's: no gap is added. +#[test] +fn a_hand_placed_end_keeps_its_point() { + let a = shape(0.0, 0.0); + let on = Point::new(80.0, 60.0); + let pts = route_points(RouteEnd::On(a, Side::Bottom, on), RouteEnd::Point(Point::new(80.0, 200.0)), Routing::Straight).unwrap(); + assert_eq!(pts.first().copied(), Some(on)); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 0181ffd21..ff6fc3b0f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -27,7 +27,7 @@ pub mod vector_faces; pub mod vector_regions; pub use board::*; -pub use board_route::{RouteEnd, attached_sides, path_handles, reshaped, route_along, route_points}; +pub use board_route::{END_GAP, RouteEnd, attached_sides, path_handles, reshaped, route_along, route_points}; pub use colors::*; pub use comments::*; pub use facilitation::*; @@ -1299,6 +1299,8 @@ fn remap_action(a: &mut proto::Action, map: &BTreeMap) { #[cfg(test)] mod blur_tests; #[cfg(test)] +mod board_route_tests; +#[cfg(test)] mod board_shape_tests; #[cfg(test)] mod boolean_hit_tests; diff --git a/crates/engine/src/board_connector_tests.rs b/crates/engine/src/board_connector_tests.rs index da5b3b924..7b6f2dc0a 100644 --- a/crates/engine/src/board_connector_tests.rs +++ b/crates/engine/src/board_connector_tests.rs @@ -2,7 +2,7 @@ //! command and a drag of the selected connector's end), curved connectors, and elbows that go //! around the objects they join. -use figcraft_doc::{BoardItem, End, NodeKind, Routing, Side}; +use figcraft_doc::{BoardItem, END_GAP, End, NodeKind, Routing, Side}; use figcraft_geom::{Point, Rect}; use serde_json::json; @@ -27,7 +27,7 @@ fn reattach_moves_one_end_and_undoes() { s.execute("board.reattach", &json!({"id": k, "end": "to", "target": c, "side": "top"})).unwrap(); assert_eq!(connector(&s, k).to, End::Node { node: c, side: Side::Top, at: None }); assert_eq!(connector(&s, k).from.node(), Some(a)); - assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, 0.0)), "arrives on c's top: {:?}", drawn(&s, k)); + assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, -END_GAP)), "arrives END_GAP above c's top: {:?}", drawn(&s, k)); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(connector(&s, k).to.node(), Some(b), "one undo step"); // Off into the canvas: the end stays there, free. The selection is the default connector. @@ -107,9 +107,9 @@ fn curved_connectors_leave_and_arrive_along_the_sides() { let NodeKind::Vector { network } = &n.kind else { panic!("a vector") }; assert_eq!((network.vertices.len(), network.segments.len()), (2, 1)); assert!(!network.segments[0].is_straight(), "one curve"); - assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]); + assert_eq!(drawn(&s, k), vec![Point::new(104.0, 50.0), Point::new(296.0, 50.0)]); // Side to side in a line: the curve's control points lie on that line, so its box is flat. - assert!(n.height.abs() < 1e-9 && (n.width - 200.0).abs() < 1e-9, "{} × {}", n.width, n.height); + assert!(n.height.abs() < 1e-9 && (n.width - 192.0).abs() < 1e-9, "{} × {}", n.width, n.height); // From a's bottom to c's top: out downwards and in from above; the box holds the whole curve. let k2 = id( &s.execute("board.connector", &json!({"from": a, "to": c, "fromSide": "bottom", "toSide": "top", "style": "curved", "label": "x"})).unwrap(), @@ -146,18 +146,18 @@ fn elbows_go_around_the_objects_they_join() { let pts = drawn(&s, k2); let (ra, rb) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(300.0, 0.0, 400.0, 100.0)); assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}"); - assert_eq!(pts.first().copied(), Some(Point::new(0.0, 50.0))); - assert_eq!(pts.last().copied(), Some(Point::new(400.0, 50.0))); + assert_eq!(pts.first().copied(), Some(Point::new(-END_GAP, 50.0))); + assert_eq!(pts.last().copied(), Some(Point::new(400.0 + END_GAP, 50.0))); assert_eq!(pts.len(), 6, "out, over, down and in: {pts:?}"); assert!(pts.iter().all(|p| p.y <= 50.0), "over the top, the nearer way round: {pts:?}"); assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles only"); // Facing each other, the short way stays. - assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]); + assert_eq!(drawn(&s, k), vec![Point::new(104.0, 50.0), Point::new(296.0, 50.0)]); // An end at the bottom of b leaving towards a above it: around b's side and up. s.execute("board.reattach", &json!({"id": k2, "end": "to", "target": b, "side": "bottom"})).unwrap(); let pts = drawn(&s, k2); assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}"); - assert_eq!(pts.last().copied(), Some(Point::new(350.0, 100.0))); + assert_eq!(pts.last().copied(), Some(Point::new(350.0, 100.0 + END_GAP))); } #[test] @@ -202,8 +202,12 @@ fn the_connector_being_drawn_is_the_one_made() { // Over b: its four attach points, the one the end is at lit, and b outlined. assert_eq!(ov.drop_target, Some(b)); let lit: Vec<[f64; 2]> = ov.attach.iter().filter(|a| a.1).map(|a| a.0).collect(); + // The lit attach point is the side's middle, on the border; the route's end sits + // END_GAP outside it along the side's normal (#1916). let end = *drawn(&s, drawing.id).last().unwrap(); - assert_eq!((ov.attach.len(), lit), (4, vec![[end.x, end.y]]), "{style}"); + assert_eq!(ov.attach.len(), 4, "{style}"); + let [lit] = lit.as_slice() else { panic!("{style}: one lit attach point, got {lit:?}") }; + assert!(((lit[0] - end.x).abs() + (lit[1] - end.y).abs() - END_GAP).abs() < 1e-9, "{style}: {lit:?} is END_GAP inside {end:?}"); } else { assert!(ov.attach.is_empty() && ov.drop_target.is_none(), "a free end"); } @@ -259,7 +263,7 @@ fn an_end_dropped_along_a_side_attaches_to_that_side() { s.pointer(&ev(PointerKind::Up, drop)).unwrap(); assert_eq!(connector(&s, k).from, End::Node { node: a, side, at: None }, "{drop:?}"); if side != Side::Auto { - assert_eq!(drawn(&s, k)[0], side.point(bounds), "the route leaves from that side"); + assert_eq!(drawn(&s, k)[0], side.point(bounds) + side.normal() * END_GAP, "the route leaves from that side, END_GAP out"); } } } @@ -307,5 +311,5 @@ fn a_connector_end_snaps_to_the_attach_point_under_the_pointer() { (connector(&s, k).from, connector(&s, k).to), (End::Node { node: a, side: Side::Right, at: None }, End::Node { node: b, side: Side::Top, at: None }) ); - assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(350.0, 200.0)]); + assert_eq!(drawn(&s, k), vec![Point::new(104.0, 50.0), Point::new(350.0, 196.0)]); } diff --git a/crates/engine/src/board_reshape_tests.rs b/crates/engine/src/board_reshape_tests.rs index 5a73267e3..ea4572cef 100644 --- a/crates/engine/src/board_reshape_tests.rs +++ b/crates/engine/src/board_reshape_tests.rs @@ -11,8 +11,8 @@ use crate::board::{route, selected_connector_handles}; use crate::board_tests::{board, connector, drawn, id, near}; use crate::tools::{PointerEvent, PointerKind}; -const A: Point = Point::new(100.0, 50.0); -const B: Point = Point::new(300.0, 250.0); +const A: Point = Point::new(104.0, 50.0); +const B: Point = Point::new(296.0, 250.0); /// Shapes a (at the origin) and b (down and to the right), 100 × 100, joined side to side. fn joined(style: &str) -> (Session, NodeId, NodeId, NodeId) { @@ -80,11 +80,11 @@ fn a_reshaped_connector_keeps_the_sides_it_left_by() { fn the_first_and_last_runs_gain_a_run_and_stay_on_their_ends() { let (mut s, _, _, k) = joined("elbow"); reshape(&mut s, k, 0.0, 150.0, 120.0).unwrap(); - assert_eq!(drawn(&s, k), [A, pt(124.0, 50.0), pt(124.0, 120.0), pt(200.0, 120.0), pt(200.0, 250.0), B]); + assert_eq!(drawn(&s, k), [A, pt(128.0, 50.0), pt(128.0, 120.0), pt(200.0, 120.0), pt(200.0, 250.0), B]); let last = drawn(&s, k).len() - 2; reshape(&mut s, k, last as f64, 250.0, 200.0).unwrap(); let pts = drawn(&s, k); - assert_eq!(pts, [A, pt(124.0, 50.0), pt(124.0, 120.0), pt(200.0, 120.0), pt(200.0, 200.0), pt(276.0, 200.0), pt(276.0, 250.0), B]); + assert_eq!(pts, [A, pt(128.0, 50.0), pt(128.0, 120.0), pt(200.0, 120.0), pt(200.0, 200.0), pt(272.0, 200.0), pt(272.0, 250.0), B]); assert!(right_angled(&pts)); } @@ -97,8 +97,8 @@ fn a_lone_run_gains_a_run_at_each_end() { let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "elbow"})).unwrap(), "id"); assert_eq!(drawn(&s, k).len(), 2); reshape(&mut s, k, 0.0, 200.0, -40.0).unwrap(); - let (l, r) = (pt(100.0, 50.0), pt(300.0, 50.0)); - assert_eq!(drawn(&s, k), [l, pt(124.0, 50.0), pt(124.0, -40.0), pt(276.0, -40.0), pt(276.0, 50.0), r]); + let (l, r) = (pt(104.0, 50.0), pt(296.0, 50.0)); + assert_eq!(drawn(&s, k), [l, pt(128.0, 50.0), pt(128.0, -40.0), pt(272.0, -40.0), pt(272.0, 50.0), r]); } #[test] diff --git a/crates/engine/src/board_tests.rs b/crates/engine/src/board_tests.rs index 6849ac421..9e99bd1c9 100644 --- a/crates/engine/src/board_tests.rs +++ b/crates/engine/src/board_tests.rs @@ -168,7 +168,7 @@ fn connectors_follow_what_they_join_and_undo_with_it() { let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id"); let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "then"})).unwrap(), "id"); let pts = drawn(&s, c); - assert!(near(pts[0], Point::new(200.0, 100.0)) && near(pts[1], Point::new(400.0, 100.0)), "{pts:?}"); + assert!(near(pts[0], Point::new(204.0, 100.0)) && near(pts[1], Point::new(396.0, 100.0)), "{pts:?}"); assert_eq!(node(&s, c).stroke.cap_end, StrokeCap::TriangleArrow); // Its label sits at the middle. let label = crate::board::label_of(s.doc().unwrap(), c).unwrap(); @@ -178,19 +178,19 @@ fn connectors_follow_what_they_join_and_undo_with_it() { s.execute("board.setStyle", &json!({"ids": [c], "style": "elbow"})).unwrap(); s.execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 300})).unwrap(); let pts = drawn(&s, c); - assert!(near(pts[0], Point::new(200.0, 100.0)) && near(*pts.last().unwrap(), Point::new(400.0, 400.0)), "{pts:?}"); + assert!(near(pts[0], Point::new(204.0, 100.0)) && near(*pts.last().unwrap(), Point::new(396.0, 400.0)), "{pts:?}"); assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles: {pts:?}"); // Undo takes the move back and the route with it. s.execute("edit.undo", &json!({})).unwrap(); - assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0))); + assert!(near(*drawn(&s, c).last().unwrap(), Point::new(396.0, 100.0))); // A deleted end stays where it was, as a point. s.execute("edit.delete", &json!({"ids": [b]})).unwrap(); assert!(matches!(connector(&s, c).to, End::Point { .. })); - assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0))); + assert!(near(*drawn(&s, c).last().unwrap(), Point::new(396.0, 100.0))); // A connector moved on its own takes its free end along; the attached one stays. s.execute("transform.move", &json!({"ids": [c], "dx": 0, "dy": 50})).unwrap(); - assert!(matches!(connector(&s, c).to, End::Point { x, y } if x == 400.0 && y == 150.0), "{:?}", connector(&s, c)); - assert!(near(drawn(&s, c)[0], Point::new(200.0, 100.0))); + assert!(matches!(connector(&s, c).to, End::Point { x, y } if x == 396.0 && y == 150.0), "{:?}", connector(&s, c)); + assert!(near(drawn(&s, c)[0], Point::new(204.0, 100.0))); // Deleting the connector takes its label. s.execute("edit.delete", &json!({"ids": [c]})).unwrap(); assert!(!s.doc().unwrap().contains(label)); @@ -210,8 +210,8 @@ fn routes_are_the_same_for_the_same_document() { let doc = s.doc().unwrap(); let r = route(doc, &c).unwrap(); assert_eq!(r, route(&doc.clone(), &c).unwrap()); - // Down out of the bottom, across halfway, down into the point. - assert_eq!(r, vec![Point::new(80.0, 160.0), Point::new(80.0, 330.0), Point::new(300.0, 330.0), Point::new(300.0, 500.0)]); + // Down out of the bottom (END_GAP below it), across halfway, down into the point. + assert_eq!(r, vec![Point::new(80.0, 164.0), Point::new(80.0, 332.0), Point::new(300.0, 332.0), Point::new(300.0, 500.0)]); } #[test] @@ -338,7 +338,7 @@ fn boards_sync_in_live_sessions() { let doc = room.server.document(); let NodeKind::Vector { network } = &doc.node(c).unwrap().kind else { panic!("not a vector") }; let end = doc.world_transform(c) * network.vertices.last().unwrap().point(); - assert_eq!(end, Point::new(400.0, 350.0), "to the moved sticky's facing side"); + assert_eq!(end, Point::new(396.0, 350.0), "to END_GAP before the moved sticky's facing side"); assert_eq!(doc.node(a).unwrap().fills[0].solid_color(), board_color("violet")); } @@ -410,7 +410,9 @@ fn a_press_on_a_stickys_background_picks_and_drags_it() { s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 450.0, 1.0)).unwrap(); assert_eq!(s.selection(), vec![a]); assert_eq!(node(&s, a).transform.translation().y, 300.0); - assert!(near(drawn(&s, c)[0], Point::new(200.0, 400.0)), "the connector followed the drag: {:?}", drawn(&s, c)); + // Its own connector's start, END_GAP outside its edge, is no snap target for it (#1916). + assert_eq!(node(&s, a).transform.translation().x, 0.0, "not nudged sideways by its own connector"); + assert!(near(drawn(&s, c)[0], Point::new(204.0, 400.0)), "the connector followed the drag: {:?}", drawn(&s, c)); } #[test] diff --git a/crates/engine/src/board_tools_tests.rs b/crates/engine/src/board_tools_tests.rs index 6862e4219..24e6df7dd 100644 --- a/crates/engine/src/board_tools_tests.rs +++ b/crates/engine/src/board_tools_tests.rs @@ -100,18 +100,18 @@ fn routes_take_named_sides_and_free_points() { let side = |n, side| End::Node { node: n, side, at: None }; let p = |x, y| End::Point { x, y }; let pt = Point::new; - // Side to side: out, across halfway, in. - assert_eq!(elbow(side(a, Side::Right), side(b, Side::Left)), vec![pt(100.0, 50.0), pt(200.0, 50.0), pt(200.0, 350.0), pt(300.0, 350.0)]); + // Side to side: out (from END_GAP outside each side), across halfway, in. + assert_eq!(elbow(side(a, Side::Right), side(b, Side::Left)), vec![pt(104.0, 50.0), pt(200.0, 50.0), pt(200.0, 350.0), pt(296.0, 350.0)]); // A side and a top or bottom: one turn. - assert_eq!(elbow(side(a, Side::Right), side(b, Side::Top)), vec![pt(100.0, 50.0), pt(350.0, 50.0), pt(350.0, 300.0)]); - assert_eq!(elbow(side(a, Side::Bottom), side(b, Side::Left)), vec![pt(50.0, 100.0), pt(50.0, 350.0), pt(300.0, 350.0)]); + assert_eq!(elbow(side(a, Side::Right), side(b, Side::Top)), vec![pt(104.0, 50.0), pt(350.0, 50.0), pt(350.0, 296.0)]); + assert_eq!(elbow(side(a, Side::Bottom), side(b, Side::Left)), vec![pt(50.0, 104.0), pt(50.0, 350.0), pt(296.0, 350.0)]); // Two free points: the longer way first; in a line, no turns. assert_eq!(elbow(p(0.0, 0.0), p(100.0, 40.0)), vec![pt(0.0, 0.0), pt(50.0, 0.0), pt(50.0, 40.0), pt(100.0, 40.0)]); assert_eq!(elbow(p(0.0, 0.0), p(100.0, 0.0)), vec![pt(0.0, 0.0), pt(100.0, 0.0)]); // An automatic side faces the other end. let straight = route(doc, &figcraft_doc::Connector { from: side(a, Side::Auto), to: p(50.0, 500.0), routing: figcraft_doc::Routing::Straight, path: None }); - assert_eq!(straight.unwrap(), vec![pt(50.0, 100.0), pt(50.0, 500.0)]); + assert_eq!(straight.unwrap(), vec![pt(50.0, 104.0), pt(50.0, 500.0)]); assert_eq!(Side::from_id("TOP"), Some(Side::Top)); assert_eq!(Side::from_id("inside"), None); // The same place twice is refused, as are a side that doesn't exist and a point without numbers. @@ -128,10 +128,10 @@ fn a_connector_follows_a_layer_whose_section_moves_and_ignores_unrelated_edits() let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id"); let b = id(&s.execute("board.stamp", &json!({"x": 800, "y": 150})).unwrap(), "id"); let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight"})).unwrap(), "id"); - assert!(near(drawn(&s, c)[0], Point::new(250.0, 150.0))); + assert!(near(drawn(&s, c)[0], Point::new(254.0, 150.0))); // Only the section is edited; the sticky inside it moves with it, and so does the connector. s.execute("transform.move", &json!({"ids": [sec], "dx": 0, "dy": 100})).unwrap(); - assert!(near(drawn(&s, c)[0], Point::new(250.0, 250.0)), "{:?}", drawn(&s, c)); + assert!(near(drawn(&s, c)[0], Point::new(254.0, 250.0)), "{:?}", drawn(&s, c)); // An edit that touches neither end leaves the connector as it was. let before = s.doc().unwrap().node_arc(c).cloned().unwrap(); s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap(); diff --git a/crates/engine/src/boards/board/tools.rs b/crates/engine/src/boards/board/tools.rs index cf4753357..71f0dcc5b 100644 --- a/crates/engine/src/boards/board/tools.rs +++ b/crates/engine/src/boards/board/tools.rs @@ -133,13 +133,19 @@ impl Session { (to.node().is_none() || to.node() != from.node()).then_some(to) } - /// The object `end` is on (if any) outlined, with its attach points; the one the end is at - /// (`at`, else its named side's) lit. + /// The object `end` is on (if any) outlined, with its attach points (the side middles, on the + /// border); the one the end is at lit: the nearest to `at` (a route's end sits `END_GAP` outside + /// its side's middle, #1916), else its named side's. fn show_attach(&mut self, end: Option, at: Option) { let attach = match end { Some(End::Node { node, side, .. }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| { let on = at.or_else(|| (side != Side::Auto).then(|| side.point(b))); - SIDES.map(|s| s.point(b)).map(|p| ([p.x, p.y], on.is_some_and(|o| (o - p).hypot() < 1e-6))).to_vec() + let pts = SIDES.map(|s| s.point(b)); + let lit = on.and_then(|o| { + let near = pts.iter().enumerate().map(|(i, p)| (i, (o - *p).hypot())).filter(|(_, d)| *d <= figcraft_doc::END_GAP + 1e-6); + near.min_by(|x, y| x.1.total_cmp(&y.1)).map(|(i, _)| i) + }); + pts.iter().enumerate().map(|(i, p)| ([p.x, p.y], lit == Some(i))).collect() }), _ => None, }; diff --git a/crates/engine/src/canvas/snap.rs b/crates/engine/src/canvas/snap.rs index fab63810c..97c23fa6c 100644 --- a/crates/engine/src/canvas/snap.rs +++ b/crates/engine/src/canvas/snap.rs @@ -4,7 +4,7 @@ //! guide lines (with spacing labels) for the canvas to draw in red. Vector points also snap to //! other points (see `vectoredit::snap`); landing on one shows a marker (a zero-length guide). -use figcraft_doc::{Document, NodeId}; +use figcraft_doc::{BoardItem, Document, NodeId}; use figcraft_geom::{Affine, Point, Rect, Vec2, transform_rect_bbox}; use serde::Serialize; @@ -94,10 +94,22 @@ impl Targets { t.ys.push((y, b.x0, b.x1)); } } - // Siblings and, at the top level, all top-level layers. + // Siblings and, at the top level, all top-level layers. A board connector joined to a + // moving layer re-routes with it, as does its label: no snapping to where its own route + // starts (#1916). + let follows = |id: NodeId| match doc.node(id).and_then(|n| n.board.as_ref()) { + Some(BoardItem::Connector(k)) => [k.from.node(), k.to.node()].into_iter().flatten().any(|e| moving.contains(&e)), + _ => false, + }; if let Some(p) = doc.node(parent) { for c in &p.children { - if moving.contains(c) || !doc.node(*c).is_some_and(|n| n.visible) { + let Some(n) = doc.node(*c).filter(|n| n.visible) else { continue }; + let joined = match n.board.as_ref() { + Some(BoardItem::Connector(_)) => follows(*c), + Some(BoardItem::Label { connector }) => follows(*connector), + _ => false, + }; + if moving.contains(c) || joined { continue; } if let Some(b) = bbox(*c) { diff --git a/crates/engine/src/connector_anywhere_tests.rs b/crates/engine/src/connector_anywhere_tests.rs index 15776fb23..f979eca57 100644 --- a/crates/engine/src/connector_anywhere_tests.rs +++ b/crates/engine/src/connector_anywhere_tests.rs @@ -46,7 +46,7 @@ fn without_cmd_an_end_keeps_snapping_to_a_side() { let end = *drawn(&s, k).last().unwrap(); drag_end(&mut s, end, Point::new(650.0, 25.0), false); assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Right, at: None }); - assert!(near(*drawn(&s, k).last().unwrap(), Point::new(700.0, 50.0))); + assert!(near(*drawn(&s, k).last().unwrap(), Point::new(700.0 + figcraft_doc::END_GAP, 50.0)), "END_GAP outside the side"); } #[test] diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 2c8cce032..8937ffa5a 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -619,7 +619,7 @@ fn other_layer_kinds_import_as_the_closest_layers() { let world = doc.world_transform(link.id); let ends: Vec<(f64, f64)> = network_of(&doc, "Link").vertices.iter().map(|v| world * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect(); - assert_eq!(ends, [(0.0, 300.0), (200.0, 300.0), (200.0, 50.0), (400.0, 50.0)]); + assert_eq!(ends, [(0.0, 300.0), (198.0, 300.0), (198.0, 50.0), (396.0, 50.0)]); assert_eq!(link.stroke.cap_end, StrokeCap::TriangleArrow); // A table: a frame. assert!(matches!(named(&doc, "Grid").kind, NodeKind::Frame)); @@ -656,9 +656,9 @@ fn connectors_without_a_saved_path_follow_their_line_style() { // Curved: one curve from A's right side to B's left, leaving and arriving level. let curve = network_of(&doc, "Curve"); let ends: Vec<_> = curve.vertices.iter().map(|v| world("Curve") * figcraft_doc::geom::Point::new(v.x, v.y)).collect(); - assert_eq!(ends.iter().map(|p| (p.x, p.y)).collect::>(), [(100.0, 650.0), (300.0, 650.0)]); + assert_eq!(ends.iter().map(|p| (p.x, p.y)).collect::>(), [(104.0, 650.0), (296.0, 650.0)], "END_GAP outside the sides"); assert_eq!(curve.segments.len(), 1); - assert_eq!((curve.segments[0].ta, curve.segments[0].tb), ((100.0, 0.0), (-100.0, 0.0)), "the curve leaves and arrives along the sides"); + assert_eq!((curve.segments[0].ta, curve.segments[0].tb), ((96.0, 0.0), (-96.0, 0.0)), "the curve leaves and arrives along the sides"); // Straight: unchanged, to the points the file gives on the layers. let line: Vec<_> = network_of(&doc, "Line").vertices.iter().map(|v| world("Line") * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect(); diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs index 554d117d9..867ba139a 100644 --- a/crates/ui-egui/src/board_ui_tests.rs +++ b/crates/ui-egui/src/board_ui_tests.rs @@ -290,7 +290,7 @@ fn a_selected_connector_shows_its_ends_as_handles() { let b = u.app.session.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap()["id"].clone(); u.app.session.execute("board.connector", &json!({"from": a, "to": b, "style": "straight"})).unwrap(); let shapes = flat(&u.frame_output(vec![]).shapes); - let ends = [u.app.to_screen(200.0, 100.0), u.app.to_screen(400.0, 100.0)]; + let ends = [u.app.to_screen(204.0, 100.0), u.app.to_screen(396.0, 100.0)]; for end in ends { let ring = shapes.iter().any(|s| matches!(s, egui::Shape::Circle(c) if (c.center - end).length() < 0.5 && c.radius == 5.0)); assert!(ring, "a handle at {end:?}"); diff --git a/docs/boards-parity.md b/docs/boards-parity.md index 7526d39aa..689dc60a6 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,6 +1,6 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-31 · **Change:** minor (a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-04 · **Change:** minor (a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill and connectors leaving one side run together before they part (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) **Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind (`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that @@ -34,10 +34,10 @@ all end with the same geometry. | Sticky options: hide the author, change size presets, sticky stacks | partial | `board.sticky {author: false}`; no size presets or stacks | | Shapes with text | done | rectangle, rounded rectangle, ellipse, diamond, triangle, speech bubble, chevron; text centred and wrapping inside the outline; shape type and text size from the contextual bar | | Shape auto-fit to its text, more shapes (cylinder, cross, star…), shape libraries | missing | | -| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed; an end dropped on the outer half of an object takes the side it is towards (a drop along a whole edge chooses that edge), one in the middle takes the facing side; re-routed when either end moves or resizes; a deleted end stays where it was | +| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed; an end dropped on the outer half of an object takes the side it is towards (a drop along a whole edge chooses that edge), one in the middle takes the facing side; re-routed when either end moves or resizes; a deleted end stays where it was; an end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), so the stroke never lies on the fill and connectors leaving one side run together along the side's normal before they part (#1916) | | Shapes with text | done | 23 outlines: rectangle, rounded rectangle, ellipse, diamond, triangle up and down, parallelogram right and left, trapezoid, pentagon, hexagon, octagon, star, plus, arrow left and right, chevron, speech bubble, and the flowchart symbols database, document, predefined process, manual input and internal storage; text centred and wrapping inside the outline; shape type and text size from the contextual bar | | Shape auto-fit to its text, the advanced shape libraries (engineering and other icon shapes) | missing | | -| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed, and within 10 px of one the end keeps that side; re-routed when either end moves or resizes; a deleted end stays where it was | +| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed, and within 10 px of one the end keeps that side; re-routed when either end moves or resizes; a deleted end stays where it was; an end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), so the stroke never lies on the fill and connectors leaving one side run together along the side's normal before they part (#1916) | | Create the next shape already connected | done | a selected sticky or shape shows a "+" beside each side; hovering one shows where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector as the Connector tool makes one, and starts typing in it; one undo step (`board.addConnected {id?, side?, edit?}`, #1597). Shift+Cmd/Ctrl+Enter on a selected (or edited) shape adds the next shape on its right, joined the same way, and goes on typing; on a sticky it adds a sticky beside it without a connector, as before | | Curved connectors, elbows around objects, dragging an end to re-attach | done | `board.setStyle {style: curved}` (and the contextual bar): one curve leaving and arriving along the sides; an elbow whose short way would cross what it joins goes around their boxes, 24 units clear; a selected connector shows its two ends as handles, and dragging one onto an object attaches it there (the object is outlined while dragging), onto empty canvas leaves it free (`board.reattach {id, end, target, side?}`, one undo step) | | A connector end on any part of an object | done | holding Cmd/Ctrl while moving an end places it where the pointer is on the object, as a share of its box, so it follows moves and resizes; the line leaves by the side facing the other end. Without the key an end snaps to a side | @@ -70,6 +70,7 @@ all end with the same geometry. | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | M12, #1916: a connector end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), as FigJam's do (the issue's screenshots: the line starts a few pixels outside, with a round cap, and connectors leaving one side run together along the side's normal before they fan out), so the stroke never lies on the fill; the `.jam` importer's routes, re-attached ends, path handles and labels follow the route's points; an end placed by hand with Cmd/Ctrl held keeps its exact point. Tests: `board_route_tests` (three). Numbers unchanged. | | 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | | 2026-10-31 | patch | M12, #1708: a connector end dropped on the outer half of a shape takes the side it is towards, instead of only within 10 px of a side's middle, so a drop anywhere along an edge chooses that edge (dragging a selected connector's end and with the connector tool, ring included); a drop in the middle of a shape still takes the facing side. | | 2026-10-31 | minor | M12, #1710: a selected elbow or curved connector shows path handles. Dragging a run moves it and a first or last run gains an extra run to stay on its end; a curve's middle goes where it is dragged (`board.reshape`, one undo step). The shape travels with what the connector joins, is saved with the file, and goes back to routing itself when the style changes or an end moves to something else. |