Connectors: glue ends to connection sites, reroute on move/resize, shape.connect / shape.sites commands, site feedback on canvas

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-06 00:49:15 -04:00
parent 639957c68a
commit 74ea37b36a
6 changed files with 363 additions and 5 deletions

View File

@ -11,6 +11,16 @@ use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"shape.connect",
"Connect Shapes",
[],
None,
"{from: shape id, to: shape id, fromSite?, toSite?: site index (default: the closest pair), id?: existing line to glue (else a new connector), preset?: straightConnector1|bentConnector3|curvedConnector3} → {id}",
has_slide,
connect
),
cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites),
cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by),
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds),
@ -664,3 +674,60 @@ fn media_options(s: &mut Session, p: &Value) -> Result<Value> {
})?;
Ok(json!({}))
}
fn shape_id(p: &Value, key: &str) -> Option<slidecraft_model::ShapeId> {
p.get(key).and_then(Value::as_u64).map(|v| slidecraft_model::ShapeId(v as u32))
}
fn sites(s: &mut Session, p: &Value) -> Result<Value> {
let id = shape_id(p, "id").ok_or_else(|| bad("shape.sites", "missing `id`"))?;
let st = s.doc()?;
let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
}
fn connect(s: &mut Session, p: &Value) -> Result<Value> {
let (Some(from), Some(to)) = (shape_id(p, "from"), shape_id(p, "to")) else { return Err(bad("shape.connect", "missing `from` / `to`")) };
let st = s.doc()?;
let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();
let (a, b) = (site_list(from), site_list(to));
if a.is_empty() || b.is_empty() {
return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)"));
}
let pick = |v: &[slidecraft_geom::Point], key: &str| usize_param(p, key).filter(|i| *i < v.len());
let (ia, ib) = match (pick(&a, "fromSite"), pick(&b, "toSite")) {
(Some(i), Some(j)) => (i, j),
(fi, fj) => {
let mut best = (f64::MAX, 0, 0);
for (i, pa) in a.iter().enumerate().filter(|(i, _)| fi.is_none_or(|f| f == *i)) {
for (j, pb) in b.iter().enumerate().filter(|(j, _)| fj.is_none_or(|f| f == *j)) {
let d = (*pa - *pb).hypot();
if d < best.0 {
best = (d, i, j);
}
}
}
(best.1, best.2)
}
};
let (p0, p1) = (a[ia], b[ib]);
let id = match shape_id(p, "id") {
Some(id) => id,
None => {
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
let r = s.execute("shape.insert", &json!({"preset": preset, "rect": [p0.x, p0.y, 1.0, 1.0]}))?;
slidecraft_model::ShapeId(r.get("id").and_then(Value::as_u64).ok_or_else(|| bad("shape.connect", "insert failed"))? as u32)
}
};
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.connect", "no slide"))?;
let sh = slidecraft_model::find_shape_mut(list, id).ok_or_else(|| bad("shape.connect", "no such line"))?;
if !sh.is_line() {
return Err(bad("shape.connect", "`id` is not a line or connector"));
}
sh.xfrm = Some(crate::tools::line_xfrm(p0, p1, 0.0));
sh.kind = slidecraft_model::ShapeKind::Connector { start: Some((from, ia as u32)), end: Some((to, ib as u32)) };
Ok(())
})?;
Ok(json!({"id": id.0, "fromSite": ia, "toSite": ib}))
}

View File

@ -0,0 +1,137 @@
//! Connectors glued to shapes: connection sites, snapping line ends to them, and rerouting attached
//! connectors whenever the shapes they connect move, resize or rotate.
use slidecraft_geom::{Point, Xfrm};
use slidecraft_model::{Presentation, Shape, ShapeId, ShapeKind};
use crate::Selection;
use crate::cmd::xfrm_of;
/// Connection sites of `s` in slide coordinates (index = site index stored on connectors).
pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec<Point> {
if s.is_line() || matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Ink { .. }) {
return vec![];
}
let x = xfrm_of(doc, sel, s);
let mut local = slidecraft_render::shape_geometry(s, x.w, x.h).sites;
if local.is_empty() {
local = vec![Point::new(x.w / 2.0, 0.0), Point::new(0.0, x.h / 2.0), Point::new(x.w / 2.0, x.h), Point::new(x.w, x.h / 2.0)];
}
let a = x.affine();
local.into_iter().map(|p| a * p).collect()
}
/// The nearest connection site within `radius` of `p` on any visible shape except `exclude`.
pub fn nearest_site(
doc: &Presentation,
sel: &Selection,
shapes: &[Shape],
p: Point,
radius: f64,
exclude: Option<ShapeId>,
) -> Option<(ShapeId, u32, Point)> {
let mut best: Option<(f64, ShapeId, u32, Point)> = None;
for s in shapes.iter().filter(|s| Some(s.id) != exclude && !s.hidden) {
for (i, q) in sites(doc, sel, s).into_iter().enumerate() {
let d = (q - p).hypot();
if d <= radius && best.is_none_or(|b| d < b.0) {
best = Some((d, s.id, i as u32, q));
}
}
}
best.map(|b| (b.1, b.2, b.3))
}
/// The shape whose sites should show while drawing or dragging a connector end near `p`.
pub fn site_shape_at(doc: &Presentation, sel: &Selection, shapes: &[Shape], p: Point, margin: f64, exclude: Option<ShapeId>) -> Option<ShapeId> {
shapes
.iter()
.rev()
.filter(|s| Some(s.id) != exclude && !s.hidden && !s.is_line() && !matches!(s.kind, ShapeKind::Ink { .. }))
.find(|s| xfrm_of(doc, sel, s).bounds().inflate(margin, margin).contains(p))
.map(|s| s.id)
}
/// Line endpoints (start, end) of a line-like shape's box.
pub fn endpoints(x: &Xfrm) -> (Point, Point) {
let a = x.affine();
(a * Point::new(0.0, 0.0), a * Point::new(x.w, x.h))
}
/// Move the ends of connectors glued to shapes on the edited slide to the current site positions.
/// Connections to shapes that no longer exist are dropped.
pub fn reroute(doc: &mut Presentation, sel: &Selection) {
let Some(list) = crate::shapes_mut(doc, sel) else { return };
if !list.iter().any(|s| matches!(s.kind, ShapeKind::Connector { start: Some(_), .. } | ShapeKind::Connector { end: Some(_), .. })) {
return;
}
let snapshot: Vec<Shape> = list.clone();
let view = doc.clone();
let site = |id: ShapeId, idx: u32| -> Option<Point> {
let s = snapshot.iter().find(|s| s.id == id)?;
let v = sites(&view, sel, s);
v.get(idx as usize).or(v.first()).copied()
};
let mut updates: Vec<(usize, Xfrm, bool, bool)> = vec![];
for (i, s) in snapshot.iter().enumerate() {
let ShapeKind::Connector { start, end } = &s.kind else { continue };
if start.is_none() && end.is_none() {
continue;
}
let x = xfrm_of(&view, sel, s);
let (mut p0, mut p1) = endpoints(&x);
let a = start.and_then(|(id, k)| site(id, k));
let b = end.and_then(|(id, k)| site(id, k));
if let Some(q) = a {
p0 = q;
}
if let Some(q) = b {
p1 = q;
}
let nx = crate::tools::line_xfrm(p0, p1, x.rot);
let changed = (nx.x - x.x).abs() > 1e-6
|| (nx.y - x.y).abs() > 1e-6
|| (nx.w - x.w).abs() > 1e-6
|| (nx.h - x.h).abs() > 1e-6
|| nx.flip_h != x.flip_h
|| nx.flip_v != x.flip_v;
let drop_a = start.is_some() && a.is_none();
let drop_b = end.is_some() && b.is_none();
if changed || drop_a || drop_b {
updates.push((i, nx, drop_a, drop_b));
}
}
let Some(list) = crate::shapes_mut(doc, sel) else { return };
for (i, nx, drop_a, drop_b) in updates {
if let Some(s) = list.get_mut(i) {
s.xfrm = Some(nx);
if let ShapeKind::Connector { start, end } = &mut s.kind {
if drop_a {
*start = None;
}
if drop_b {
*end = None;
}
}
}
}
}
/// Glue end `end` (0 = start, 1 = end) of line `id` to `to` (or unglue with `None`). Plain lines
/// become connectors when glued.
pub fn set_glue(list: &mut [Shape], id: ShapeId, end: u8, to: Option<(ShapeId, u32)>) {
let Some(s) = list.iter_mut().find(|s| s.id == id) else { return };
if !matches!(s.kind, ShapeKind::Connector { .. }) {
if to.is_none() || !matches!(s.kind, ShapeKind::Shape) {
return;
}
s.kind = ShapeKind::Connector { start: None, end: None };
}
if let ShapeKind::Connector { start, end: e } = &mut s.kind {
if end == 0 {
*start = to;
} else {
*e = to;
}
}
}

View File

@ -8,6 +8,7 @@
#![forbid(unsafe_code)]
pub mod cmd;
pub mod connect;
pub mod guard;
pub mod keys;
pub mod links;
@ -381,6 +382,7 @@ impl Session {
let mut doc = (*st.doc).clone();
let mut sel = st.selection.clone();
let r = f(&mut doc, &mut sel)?;
connect::reroute(&mut doc, &sel);
st.doc = Arc::new(doc);
st.selection = sel;
st.revision += 1;

View File

@ -248,3 +248,65 @@ fn themes_and_slide_size() {
let v = s.execute("slide.inspect", &json!({})).unwrap();
assert!(v["shapes"].as_array().unwrap().iter().any(|x| x["placeholder"] == "sldNum"));
}
fn line_ends(s: &Session, id: u64) -> (slidecraft_geom::Point, slidecraft_geom::Point) {
let st = s.doc().unwrap();
let sh = st.shape(slidecraft_model::ShapeId(id as u32)).unwrap();
connect::endpoints(&cmd::xfrm_of(&st.doc, &st.selection, sh))
}
#[test]
fn connectors_follow_glued_shapes() {
let mut s = session();
let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 50]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 300, 100, 50]})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("shape.connect", &json!({"from": a, "to": b, "preset": "bentConnector3"})).unwrap();
let c = r["id"].as_u64().unwrap();
let (p0, p1) = line_ends(&s, c);
// Closest pair: right side of A → left side of B... or bottom/top; either way on the boxes.
let sites_a: Vec<Vec<f64>> = serde_json::from_value(s.execute("shape.sites", &json!({"id": a})).unwrap()).unwrap();
assert!(sites_a.iter().any(|q| (q[0] - p0.x).abs() < 1e-6 && (q[1] - p0.y).abs() < 1e-6));
// Move B: the connector's end follows, its start stays.
s.execute("edit.select", &json!({"ids": [b]})).unwrap();
s.execute("shape.move", &json!({"dx": 50, "dy": 20})).unwrap();
let (q0, q1) = line_ends(&s, c);
assert!((q0 - p0).hypot() < 1e-6);
assert!(((q1 - p1) - slidecraft_geom::Vec2::new(50.0, 20.0)).hypot() < 1e-6, "{p1:?} → {q1:?}");
// Undo puts both back in one step.
s.execute("edit.undo", &json!({})).unwrap();
assert!((line_ends(&s, c).1 - p1).hypot() < 1e-6);
// Deleting a glued shape unglues that end and leaves the line where it was.
s.execute("edit.select", &json!({"ids": [a]})).unwrap();
s.execute("edit.delete", &json!({})).unwrap();
let st = s.doc().unwrap();
let sh = st.shape(slidecraft_model::ShapeId(c as u32)).unwrap();
assert!(matches!(sh.kind, slidecraft_model::ShapeKind::Connector { start: None, end: Some(_) }));
}
#[test]
fn drawing_a_line_between_shapes_glues_it() {
let mut s = session();
let a = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap();
s.execute("edit.deselect", &json!({})).unwrap();
s.tool.kind = tools::ToolKind::Shape { preset: "straightConnector1".into() };
let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0 };
// Right site of the ellipse (200,150) → left site of the rect (400,150), a little off.
s.pointer(ev(tools::PointerKind::Down, 202.0, 151.0)).unwrap();
s.pointer(ev(tools::PointerKind::Drag, 300.0, 150.0)).unwrap();
assert!(!s.tool.sites.is_empty() || s.tool.glue.is_none());
s.pointer(ev(tools::PointerKind::Drag, 398.0, 149.0)).unwrap();
assert!(s.tool.glue.is_some());
s.pointer(ev(tools::PointerKind::Up, 398.0, 149.0)).unwrap();
let st = s.doc().unwrap();
let line = st.shapes().iter().find(|x| x.is_line()).unwrap();
match line.kind {
slidecraft_model::ShapeKind::Connector { start: Some((x, _)), end: Some((y, _)) } => {
assert_eq!((x.0 as u64, y.0 as u64), (a, b));
}
ref k => panic!("not glued: {k:?}"),
}
let id = line.id.0 as u64;
let (p0, p1) = line_ends(&s, id);
assert!((p0.x - 200.0).abs() < 1e-6 && (p1.x - 400.0).abs() < 1e-6, "{p0:?} {p1:?}");
}

View File

@ -121,6 +121,10 @@ pub struct ToolState {
pub guides: Vec<Guide>,
pub hover: Option<Hit>,
pub ink_preview: Vec<(f64, f64, f32)>,
/// Connection sites to show (slide coordinates) while drawing or dragging a line end, and the
/// site the end would glue to.
pub sites: Vec<Point>,
pub glue: Option<Point>,
}
impl ToolState {
@ -206,6 +210,13 @@ impl Session {
match ev.kind {
PointerKind::Move => {
self.tool.hover = self.hit_test(p, tol);
let line_tool = matches!(&self.tool.kind, ToolKind::Shape { preset } if slidecraft_geom::preset::is_line_like(preset));
if line_tool {
self.update_sites(p, tol, None);
} else {
self.tool.sites.clear();
self.tool.glue = None;
}
Ok(Value::Null)
}
PointerKind::Down => self.down(p, ev.mods, tol),
@ -393,6 +404,17 @@ impl Session {
sh.xfrm = Some(Xfrm { x: o.x + d.x, y: o.y + d.y, ..*o });
}
}
// A connector dragged away from shapes that stay put comes unglued at those ends.
let moved: Vec<ShapeId> = origs2.iter().map(|o| o.0).collect();
for sh in list.iter_mut().filter(|s| moved.contains(&s.id)) {
if let ShapeKind::Connector { start, end } = &mut sh.kind {
for c in [start, end] {
if c.is_some_and(|(to, _)| !moved.contains(&to)) {
*c = None;
}
}
}
}
Ok(())
})?;
self.tool.drag =
@ -411,8 +433,18 @@ impl Session {
if mods.shift {
q = constrain_45(other, q);
}
let glue = self.update_sites(q, tol, Some(id));
if let Some((_, _, at)) = glue {
q = at;
}
let (s0, s1) = if end == 0 { (q, p1) } else { (p0, q) };
self.set_xfrm(id, line_xfrm(s0, s1, orig.rot))?;
self.edit(|doc, sel| {
if let Some(list) = crate::shapes_mut(doc, sel) {
crate::connect::set_glue(list, id, end, glue.map(|g| (g.0, g.1)));
}
Ok(())
})?;
}
Drag::Rotate { ids, center, start, origs } => {
let ang = (p.y - center.y).atan2(p.x - center.x);
@ -474,8 +506,12 @@ impl Session {
Drag::Create { start } => {
let line = matches!(&self.tool.kind, ToolKind::Shape { preset } if slidecraft_geom::preset::is_line_like(preset));
let x = if line {
let q = if mods.shift { constrain_45(start, p) } else { p };
line_xfrm(start, q, 0.0)
let mut q = if mods.shift { constrain_45(start, p) } else { p };
if let Some((_, _, at)) = self.update_sites(q, tol, None) {
q = at;
}
let s0 = self.glue_at(start, tol).map(|g| g.2).unwrap_or(start);
line_xfrm(s0, q, 0.0)
} else {
let mut r = Rect::from_points(start, p);
if mods.shift {
@ -515,6 +551,8 @@ impl Session {
fn up(&mut self, p: Point, mods: Mods) -> Result<Value> {
let drag = self.tool.drag.take();
self.tool.guides.clear();
self.tool.sites.clear();
self.tool.glue = None;
let mut out = Value::Null;
match drag {
Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => {
@ -566,7 +604,20 @@ impl Session {
};
let r = self.execute("shape.insert", &json!({"preset": preset, "rect": [rect.x, rect.y, rect.w, rect.h], "flip": format!("{}{}", if rect.flip_h { "h" } else { "" }, if rect.flip_v { "v" } else { "" })}))?;
if let (Some(id), Some(x)) = (r.get("id").and_then(Value::as_u64), preview.filter(|_| dragged && line)) {
let _ = self.set_xfrm_cmd(ShapeId(id as u32), x);
let id = ShapeId(id as u32);
let _ = self.set_xfrm_cmd(id, x);
// Glue the ends that landed on connection sites.
let (p0, p1) = crate::connect::endpoints(&x);
let (g0, g1) = (self.glue_at(p0, 4.0), self.glue_at(p1, 4.0));
if g0.is_some() || g1.is_some() {
let _ = self.edit(|doc, sel| {
if let Some(list) = crate::shapes_mut(doc, sel) {
crate::connect::set_glue(list, id, 0, g0.map(|g| (g.0, g.1)));
crate::connect::set_glue(list, id, 1, g1.map(|g| (g.0, g.1)));
}
Ok(())
});
}
}
r
}
@ -647,6 +698,31 @@ impl Session {
self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_)))
}
/// The connection site within glue range of `p` (excluding shape `exclude`).
fn glue_at(&self, p: Point, tol: f64) -> Option<(ShapeId, u32, Point)> {
let st = self.active()?;
crate::connect::nearest_site(&st.doc, &st.selection, st.shapes(), p, (tol * 2.0).max(6.0), None)
}
/// Show the sites of the shape near `p` and return the site an end at `p` would glue to.
fn update_sites(&mut self, p: Point, tol: f64, exclude: Option<ShapeId>) -> Option<(ShapeId, u32, Point)> {
let (sites, glue) = match self.active() {
Some(st) => {
let shapes = st.shapes();
let near = crate::connect::site_shape_at(&st.doc, &st.selection, shapes, p, tol * 3.0, exclude);
let sites = near
.and_then(|id| shapes.iter().find(|s| s.id == id))
.map(|s| crate::connect::sites(&st.doc, &st.selection, s))
.unwrap_or_default();
(sites, crate::connect::nearest_site(&st.doc, &st.selection, shapes, p, (tol * 2.0).max(6.0), exclude))
}
None => (vec![], None),
};
self.tool.sites = sites;
self.tool.glue = glue.map(|g| g.2);
glue
}
fn set_xfrm(&mut self, id: ShapeId, x: Xfrm) -> Result<()> {
self.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("tool", "no slide"))?;

View File

@ -280,8 +280,14 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
let corners = [Point::new(0.0, 0.0), Point::new(x.w, 0.0), Point::new(x.w, x.h), Point::new(0.0, x.h)].map(|q| xf.to_screen(a * q));
if sh.is_line() {
let (p0, p1) = (xf.to_screen(a * Point::new(0.0, 0.0)), xf.to_screen(a * Point::new(x.w, x.h)));
for q in [p0, p1] {
painter.circle(q, 4.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke));
let glued = match sh.kind {
slidecraft_model::ShapeKind::Connector { start, end } => [start.is_some(), end.is_some()],
_ => [false, false],
};
for (q, g) in [p0, p1].into_iter().zip(glued) {
// Glued ends are filled with the accent, like attached connector ends.
let fill = if g { t.accent } else { t.handle_fill };
painter.circle(q, 4.5, fill, Stroke::new(1.0, if g { t.handle_fill } else { t.handle_stroke }));
}
continue;
}
@ -375,6 +381,14 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
if let Some(pv) = app.session.tool.preview {
draw_preview(app, painter, xf, pv, t);
}
// Connection sites of the shape under a connector end, and the site it would glue to.
for q in &app.session.tool.sites {
let c = xf.to_screen(*q);
painter.circle(c, 3.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke));
}
if let Some(g) = app.session.tool.glue {
painter.circle(xf.to_screen(g), 5.0, t.accent, Stroke::new(1.5, t.handle_fill));
}
if app.session.tool.ink_preview.len() > 1
&& let ToolKind::Ink { color, width, mode } = &app.session.tool.kind
{