M12: connectors attached to a side start 4 pt outside the border (#1916)

A connector end attached to a side was routed from the middle of that side, so the stroke's
centre line started on the border and half its width plus the round cap lay on the fill, and
several connectors leaving one side piled up into a knot sticking into the card. FigJam starts
the line a few pixels outside the border and connectors leaving one side run together along the
side's normal before they fan out (the issue's screenshots). `board_route::END_GAP` (4 pt): an end
attached to a side (`RouteEnd::Box`) starts that far outside it along the side's normal; an end
placed by hand on the object (`RouteEnd::On`) keeps its exact point; a free end is unchanged.
The pinned route coordinates in the engine, format and UI tests move with it; the overlay's
attach points stay on the border. Docs: the Connectors rows, status line and history of
boards-parity.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Trevor Brown
2026-11-03 21:08:19 -05:00
co-authored by Claude Fable 5.1
parent 4bb35d5266
commit 2dc7bed2a0
13 changed files with 136 additions and 53 deletions
+11 -4
View File
@@ -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<Vec<Point>> {
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<Side>) {
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))),
}
}
+49
View File
@@ -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));
}
+3 -1
View File
@@ -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<NodeId, NodeId>) {
#[cfg(test)]
mod blur_tests;
#[cfg(test)]
mod board_route_tests;
#[cfg(test)]
mod board_shape_tests;
#[cfg(test)]
mod boolean_hit_tests;
+15 -11
View File
@@ -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)]);
}
+6 -6
View File
@@ -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]
+12 -10
View File
@@ -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]
+7 -7
View File
@@ -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();
+9 -3
View File
@@ -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<End>, at: Option<Point>) {
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,
};
+15 -3
View File
@@ -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) {
@@ -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]
+3 -3
View File
@@ -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::<Vec<_>>(), [(100.0, 650.0), (300.0, 650.0)]);
assert_eq!(ends.iter().map(|p| (p.x, p.y)).collect::<Vec<_>>(), [(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();
+1 -1
View File
@@ -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:?}");
+4 -3
View File
@@ -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. |