From 74ea37b36a61e3411b655ec4d7652d130d9d0439 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 00:49:15 -0400 Subject: [PATCH] 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 --- crates/engine/src/cmd/shape.rs | 67 ++++++++++++++++ crates/engine/src/connect.rs | 137 +++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/tests.rs | 62 +++++++++++++++ crates/engine/src/tools.rs | 82 +++++++++++++++++++- crates/ui-egui/src/canvas.rs | 18 ++++- 6 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/connect.rs diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index dbbc522..a61cfbb 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -11,6 +11,16 @@ use crate::{Result, Session}; pub fn specs() -> Vec { 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 { })?; Ok(json!({})) } + +fn shape_id(p: &Value, key: &str) -> Option { + p.get(key).and_then(Value::as_u64).map(|v| slidecraft_model::ShapeId(v as u32)) +} + +fn sites(s: &mut Session, p: &Value) -> Result { + 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::>())) +} + +fn connect(s: &mut Session, p: &Value) -> Result { + 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})) +} diff --git a/crates/engine/src/connect.rs b/crates/engine/src/connect.rs new file mode 100644 index 0000000..87b325a --- /dev/null +++ b/crates/engine/src/connect.rs @@ -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 { + 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, +) -> 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) -> Option { + 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 = list.clone(); + let view = doc.clone(); + let site = |id: ShapeId, idx: u32| -> Option { + 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; + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f9b88fa..0ba45b6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b8b2008..d287be3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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> = 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:?}"); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 7fc7754..c56510b 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -121,6 +121,10 @@ pub struct ToolState { pub guides: Vec, pub hover: Option, 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, + pub glue: Option, } 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 = 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 { 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) -> 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"))?; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1a7ad6a..c364b97 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 {