From e28621828e5d9af9e0a09746fead2c3716e6021e Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:45:08 -0500 Subject: [PATCH 1/4] Connector default sites face the other shape; formatting set before the text survives text.set shape.connect with no sites given picked the closest pair of sites, which for a diagonal neighbour chose a side site and drew the arrow across the boxes. Each shape now uses the site that faces the other along the direction between their centres (a box mostly below is left from the bottom), which is where a person glues an arrow; the params doc says so. format.size, format.bold, format.color on a shape that has no text yet kept nothing, so the size vanished when set_text then landed the text. An empty body now keeps that formatting on the paragraph's end mark and text.set keeps the first run's properties, or those, so the two work in either order. A test for each. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/cmd/shape.rs | 2 +- crates/engine/src/cmd/shape/freeform.rs | 36 +++++++++++++++--------- crates/engine/src/cmd/text/caret.rs | 8 +++++- crates/engine/src/cmd/text/formatting.rs | 5 ++++ crates/engine/src/tests.rs | 35 +++++++++++++++++++++++ 5 files changed, 70 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 9565cdf7b..7431261c9 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { "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}", + "{from: shape id, to: shape id, fromSite?, toSite?: site index (default: the sites facing each other along the dominant direction between the shapes; a box has 0 top, 1 left, 2 bottom, 3 right; shape.sites lists them), id?: existing line to glue (else a new connector), preset?: straightConnector1|bentConnector3|curvedConnector3} → {id}", has_slide, connect ), diff --git a/crates/engine/src/cmd/shape/freeform.rs b/crates/engine/src/cmd/shape/freeform.rs index f3be4f4bb..827dc1a67 100644 --- a/crates/engine/src/cmd/shape/freeform.rs +++ b/crates/engine/src/cmd/shape/freeform.rs @@ -18,21 +18,29 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result { return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)")); } let pick = |v: &[deckcraft_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) - } + // A site left unsaid is the one facing the other shape along the dominant direction between + // the two (a box mostly below is left from the bottom), which is where a person glues an + // arrow; "the closest pair" chose a side site for a diagonal neighbour and crossed boxes. + let centre = |v: &[deckcraft_geom::Point]| { + let n = v.len().max(1) as f64; + deckcraft_geom::Point::new(v.iter().map(|q| q.x).sum::() / n, v.iter().map(|q| q.y).sum::() / n) + }; + let (ca, cb) = (centre(&a), centre(&b)); + let facing = |v: &[deckcraft_geom::Point], c: deckcraft_geom::Point, dx: f64, dy: f64| { + let len = dx.hypot(dy).max(1e-9); + v.iter() + .enumerate() + .map(|(i, q)| { + let (sx, sy) = (q.x - c.x, q.y - c.y); + let sl = sx.hypot(sy).max(1e-9); + (i, (sx * dx + sy * dy) / (sl * len)) + }) + .max_by(|p, q| p.1.total_cmp(&q.1)) + .map(|(i, _)| i) + .unwrap_or(0) }; + let ia = pick(&a, "fromSite").unwrap_or_else(|| facing(&a, ca, cb.x - ca.x, cb.y - ca.y)); + let ib = pick(&b, "toSite").unwrap_or_else(|| facing(&b, cb, ca.x - cb.x, ca.y - cb.y)); let (p0, p1) = (a[ia], b[ib]); let id = match id_param(p, "id", "shape.connect")? { Some(id) => id, diff --git a/crates/engine/src/cmd/text/caret.rs b/crates/engine/src/cmd/text/caret.rs index c7305d62e..845e72d60 100644 --- a/crates/engine/src/cmd/text/caret.rs +++ b/crates/engine/src/cmd/text/caret.rs @@ -338,7 +338,10 @@ pub(super) fn set(s: &mut Session, p: &Value) -> Result { _ => Some(sh.text.get_or_insert_with(TextBody::default)), }; let Some(body) = target else { return Err(bad("text.set", "no text here (tables need `cell`)")) }; - let keep = body.paragraphs.first().and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default(); + // The first run's formatting, or for text that is still empty the formatting its end + // mark holds (format.* before any text lands there), so a size set before text.set survives. + let keep = + body.paragraphs.first().map(|p| p.runs.first().map(|r| r.props.clone()).unwrap_or_else(|| p.end_props.clone())).unwrap_or_default(); let levels: Vec<(u8, deckcraft_model::text::ParaProps)> = body.paragraphs.iter().map(|p| (p.level, p.props.clone())).collect(); let mut nb = TextBody::from_text(&text); for (i, para) in nb.paragraphs.iter_mut().enumerate() { @@ -353,6 +356,9 @@ pub(super) fn set(s: &mut Session, p: &Value) -> Result { for r in &mut para.runs { r.props = keep.clone(); } + // The end mark keeps it too, so an empty `text` (no runs) carries the formatting to + // the text set next. + para.end_props = keep.clone(); } body.paragraphs = nb.paragraphs; Ok(()) diff --git a/crates/engine/src/cmd/text/formatting.rs b/crates/engine/src/cmd/text/formatting.rs index d942ee282..60923c9c2 100644 --- a/crates/engine/src/cmd/text/formatting.rs +++ b/crates/engine/src/cmd/text/formatting.rs @@ -72,6 +72,11 @@ pub(crate) fn format_run_levels( } _ => { let body = sh.text.get_or_insert_with(TextBody::default); + // Text that is still empty keeps the formatting on an end mark, so a size set + // before the text lands survives it; a body with no paragraph has none. + if body.paragraphs.is_empty() { + body.paragraphs.push(Default::default()); + } let eff = effective.iter().find(|(i, _)| *i == id).map(|(_, e)| e); match eff { Some(eff) => { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a687de95c..abcfccc89 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5970,6 +5970,41 @@ fn invalid_slide_index_never_falls_back_to_the_selection_in_export_commands() { let _ = std::fs::remove_dir_all(&dir); } +/// G6: with no sites given, shape.connect joins the sites that face each other along the +/// dominant direction between the two shapes (a box mostly below leaves from the bottom), not +/// "the closest pair", which for a diagonal neighbour picked a side site and crossed boxes. +#[test] +fn connector_default_sites_follow_the_dominant_direction() { + let mut s = session(); + let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 100, 50]})).unwrap()["id"].as_u64().unwrap(); + let diag = s.execute("shape.insert", &json!({"preset": "rect", "rect": [150, 200, 100, 50]})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("shape.connect", &json!({"from": a, "to": diag})).unwrap(); + assert_eq!((r["fromSite"].as_u64(), r["toSite"].as_u64()), (Some(2), Some(0)), "mostly below: bottom → top, not right → left: {r}"); + let r = s.execute("shape.connect", &json!({"from": diag, "to": a})).unwrap(); + assert_eq!((r["fromSite"].as_u64(), r["toSite"].as_u64()), (Some(0), Some(2)), "and back: top → bottom: {r}"); +} + +/// A size (or colour, bold…) set on a shape before it has any text survives the text that is +/// then set: text.set keeps the first run's properties, and an empty paragraph keeps them on its +/// end mark, which is what the agent's format.size then set_text sequence relies on. +#[test] +fn text_set_keeps_the_formatting_of_an_empty_shape() { + let mut s = session(); + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 300, 60]})).unwrap()["id"].as_u64().unwrap(); + s.execute("format.size", &json!({"size": 30})).unwrap(); + s.execute("format.bold", &json!({"on": true})).unwrap(); + s.execute("text.set", &json!({"id": id, "text": "Hello"})).unwrap(); + let props = + |s: &Session| s.doc().unwrap().shape(deckcraft_model::ShapeId(id as u32)).unwrap().text.as_ref().unwrap().paragraphs[0].runs[0].props.clone(); + let run = props(&s); + assert_eq!((run.size, run.bold), (Some(30.0), Some(true)), "{run:?}"); + // Emptied and filled again: the empty paragraph's end mark carried the formatting. + s.execute("text.set", &json!({"id": id, "text": ""})).unwrap(); + s.execute("text.set", &json!({"id": id, "text": "Again"})).unwrap(); + let run = props(&s); + assert_eq!((run.size, run.bold), (Some(30.0), Some(true)), "after an empty text.set: {run:?}"); +} + #[test] fn highlight_with_an_invalid_colour_is_rejected_and_keeps_the_highlight() { let mut s = session(); From ce394ed8f6b1ffba4cfd2bdff8072bea33d2e0cd Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:26:52 -0500 Subject: [PATCH 2/4] shape.connect takes the direction between the shapes' box centres, not the means of their sites The mean of a shape's connection sites is off its centre when the sites are not symmetric (a triangle's lie mostly along its base), which pulled the dominant direction sideways and glued an arrow to a box's side when the box was mostly below. The centres come from xfrm_of, so placeholders and tables are measured as drawn. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/cmd/shape/freeform.rs | 9 ++++----- crates/engine/src/tests.rs | 7 +++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/shape/freeform.rs b/crates/engine/src/cmd/shape/freeform.rs index 827dc1a67..42adc9f59 100644 --- a/crates/engine/src/cmd/shape/freeform.rs +++ b/crates/engine/src/cmd/shape/freeform.rs @@ -21,11 +21,10 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result { // A site left unsaid is the one facing the other shape along the dominant direction between // the two (a box mostly below is left from the bottom), which is where a person glues an // arrow; "the closest pair" chose a side site for a diagonal neighbour and crossed boxes. - let centre = |v: &[deckcraft_geom::Point]| { - let n = v.len().max(1) as f64; - deckcraft_geom::Point::new(v.iter().map(|q| q.x).sum::() / n, v.iter().map(|q| q.y).sum::() / n) - }; - let (ca, cb) = (centre(&a), centre(&b)); + // The direction runs between the shapes' box centres: the mean of the sites is off-centre + // for a shape whose sites are not symmetric (a triangle's lie mostly along its base). + let centre = |id| st.shape(id).map(|sh| xfrm_of(&st.doc, &st.selection, sh).center()).unwrap_or_default(); + let (ca, cb) = (centre(from), centre(to)); let facing = |v: &[deckcraft_geom::Point], c: deckcraft_geom::Point, dx: f64, dy: f64| { let len = dx.hypot(dy).max(1e-9); v.iter() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index abcfccc89..f002e3ddf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5982,6 +5982,13 @@ fn connector_default_sites_follow_the_dominant_direction() { assert_eq!((r["fromSite"].as_u64(), r["toSite"].as_u64()), (Some(2), Some(0)), "mostly below: bottom → top, not right → left: {r}"); let r = s.execute("shape.connect", &json!({"from": diag, "to": a})).unwrap(); assert_eq!((r["fromSite"].as_u64(), r["toSite"].as_u64()), (Some(0), Some(2)), "and back: top → bottom: {r}"); + // The direction runs between the shapes' centres, not between the means of their sites: a + // triangle's sites lie mostly along its base, which pulled the direction sideways and chose + // the box's left site for a box that is mostly below. + let tri = s.execute("shape.insert", &json!({"preset": "triangle", "rect": [400, 0, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + let below = s.execute("shape.insert", &json!({"preset": "rect", "rect": [520, 155, 100, 50]})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("shape.connect", &json!({"from": tri, "to": below})).unwrap(); + assert_eq!(r["toSite"].as_u64(), Some(0), "centre to centre is mostly down: the box's top, not its left: {r}"); } /// A size (or colour, bold…) set on a shape before it has any text survives the text that is From ec1b5cd7916afb5cb0e818d98bf3284d00431f4a Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:04:02 -0500 Subject: [PATCH 3/4] shape.connect glues to a shape inside a group, and the connector follows the group An agent run found that connecting to a grouped shape answered ok but left the connector's end `None`: the sites of a group member were computed in the group's child space, and rerouting only looked at the slide's top-level shapes, so the glue was dropped at once. Both now locate the shape anywhere in the tree with the affine onto the slide, as PowerPoint glues to grouped shapes. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/cmd/shape/freeform.rs | 14 +++++-- crates/engine/src/connect.rs | 49 ++++++++++++++++++++++--- crates/engine/src/tests.rs | 45 +++++++++++++++++++++++ 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/shape/freeform.rs b/crates/engine/src/cmd/shape/freeform.rs index 42adc9f59..c857a9b45 100644 --- a/crates/engine/src/cmd/shape/freeform.rs +++ b/crates/engine/src/cmd/shape/freeform.rs @@ -12,8 +12,14 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result { 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)); + // Anywhere in the slide's tree: a shape inside a group takes a connector too (PowerPoint + // glues to grouped shapes), its sites where the group draws them. + let shape = |id| { + crate::connect::locate(&st.doc, &st.selection, st.shapes(), id) + .ok_or_else(|| bad("shape.connect", format!("no shape {} on the current slide", id.0))) + }; + let ((sa, ta), (sb, tb)) = (shape(from)?, shape(to)?); + let (a, b) = (crate::connect::sites_in(&st.doc, &st.selection, sa, ta), crate::connect::sites_in(&st.doc, &st.selection, sb, tb)); if a.is_empty() || b.is_empty() { return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)")); } @@ -23,8 +29,8 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result { // arrow; "the closest pair" chose a side site for a diagonal neighbour and crossed boxes. // The direction runs between the shapes' box centres: the mean of the sites is off-centre // for a shape whose sites are not symmetric (a triangle's lie mostly along its base). - let centre = |id| st.shape(id).map(|sh| xfrm_of(&st.doc, &st.selection, sh).center()).unwrap_or_default(); - let (ca, cb) = (centre(from), centre(to)); + let centre = |sh, to_abs: deckcraft_geom::Affine| to_abs * xfrm_of(&st.doc, &st.selection, sh).center(); + let (ca, cb) = (centre(sa, ta), centre(sb, tb)); let facing = |v: &[deckcraft_geom::Point], c: deckcraft_geom::Point, dx: f64, dy: f64| { let len = dx.hypot(dy).max(1e-9); v.iter() diff --git a/crates/engine/src/connect.rs b/crates/engine/src/connect.rs index 8cdbecda8..5991ba619 100644 --- a/crates/engine/src/connect.rs +++ b/crates/engine/src/connect.rs @@ -1,14 +1,21 @@ //! 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 deckcraft_geom::{Point, Xfrm}; +use deckcraft_geom::{Affine, Point, Rect, Xfrm}; use deckcraft_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). +/// Connection sites of a top-level shape `s` in slide coordinates (index = site index stored on +/// connectors). For a shape inside a group use [`sites_of`] or [`sites_in`]. pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec { + sites_in(doc, sel, s, Affine::IDENTITY) +} + +/// Connection sites of `s` in slide coordinates, `to_abs` mapping the space its box lives in (the +/// slide, or the child space of the groups around it) onto the slide. +pub fn sites_in(doc: &Presentation, sel: &Selection, s: &Shape, to_abs: Affine) -> Vec { if s.is_line() || matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Ink { .. }) { return vec![]; } @@ -17,10 +24,42 @@ pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec { 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(); + let a = to_abs * x.affine(); local.into_iter().map(|p| a * p).collect() } +/// The shape `id` anywhere in `shapes` (a group's member included) with the affine that maps the +/// space its box lives in onto the slide: a connector glues to a grouped shape, as in PowerPoint, +/// and its sites are where the group draws them. +pub fn locate<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId) -> Option<(&'a Shape, Affine)> { + fn rec<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId, to_abs: Affine, depth: usize) -> Option<(&'a Shape, Affine)> { + if depth > 64 { + return None; + } + for s in shapes { + if s.id == id { + return Some((s, to_abs)); + } + if let ShapeKind::Group { children, child } = &s.kind { + let x = xfrm_of(doc, sel, s); + let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); + let nested = to_abs * deckcraft_geom::group_child_affine(&x, ch); + if let Some(f) = rec(doc, sel, children, id, nested, depth + 1) { + return Some(f); + } + } + } + None + } + rec(doc, sel, shapes, id, Affine::IDENTITY, 0) +} + +/// Connection sites of shape `id` anywhere in `shapes`, in slide coordinates; `None` when there is +/// no such shape. +pub fn sites_of(doc: &Presentation, sel: &Selection, shapes: &[Shape], id: ShapeId) -> Option> { + locate(doc, sel, shapes, id).map(|(s, a)| sites_in(doc, sel, s, a)) +} + /// The nearest connection site within `radius` of `p` on any visible shape except `exclude`. pub fn nearest_site( doc: &Presentation, @@ -68,8 +107,8 @@ pub fn reroute(doc: &mut Presentation, sel: &Selection) { 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); + // Anywhere in the tree: a connector glued to a shape that is then grouped keeps following it. + let v = sites_of(&view, sel, &snapshot, id)?; v.get(idx as usize).or(v.first()).copied() }; let mut updates: Vec<(usize, Xfrm, bool, bool)> = vec![]; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f002e3ddf..58cafb908 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6012,6 +6012,51 @@ fn text_set_keeps_the_formatting_of_an_empty_shape() { assert_eq!((run.size, run.bold), (Some(30.0), Some(true)), "after an empty text.set: {run:?}"); } +/// HH1: `shape.connect` glues to a shape inside a group, as PowerPoint does: the connector's +/// end is set to the grouped shape and follows it when the group moves. The glue was dropped at +/// once (the end stayed `None`) because rerouting only looked at the slide's top-level shapes, +/// and the sites of a grouped shape were computed in the group's child space. +#[test] +fn shape_connect_glues_to_a_shape_inside_a_group() { + use deckcraft_model::{ShapeId, ShapeKind}; + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 100, 50]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 200, 100, 50]})).unwrap()["id"].as_u64().unwrap(); + let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); + let c = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 10, 100, 50]})).unwrap()["id"].as_u64().unwrap(); + let glue = |s: &Session, id: u64| { + let st = s.doc().unwrap(); + match st.shape(ShapeId(id as u32)).unwrap().kind { + ShapeKind::Connector { start, end } => (start.map(|(i, _)| i.0 as u64), end.map(|(i, _)| i.0 as u64)), + _ => (None, None), + } + }; + let near = |p: deckcraft_geom::Point, x: f64, y: f64| (p.x - x).abs() < 1e-6 && (p.y - y).abs() < 1e-6; + // A new connector into the group. + let r = s.execute("shape.connect", &json!({"from": c, "to": a})).unwrap(); + let k = r["id"].as_u64().unwrap(); + assert_eq!(glue(&s, k), (Some(c), Some(a)), "{r}"); + let (p0, p1) = line_ends(&s, k); + assert!(near(p0, 400.0, 35.0), "from C's left site: {p0:?}"); + assert!(near(p1, 110.0, 35.0), "to A's right site in slide space: {p1:?}"); + s.execute("shape.move", &json!({"ids": [g], "dy": 100})).unwrap(); + assert_eq!(glue(&s, k), (Some(c), Some(a)), "the glue survives the move"); + let (_, p1) = line_ends(&s, k); + assert!(near(p1, 110.0, 135.0), "the connector follows the grouped shape: {p1:?}"); + // Re-gluing an existing line with `id`. + let l = s.execute("shape.insert", &json!({"preset": "straightConnector1", "rect": [600, 400, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("shape.connect", &json!({"id": l, "from": c, "to": b})).unwrap(); + assert_eq!(r["id"], l, "{r}"); + assert_eq!(glue(&s, l), (Some(c), Some(b)), "{r}"); + let (_, p1) = line_ends(&s, l); + assert!(near(p1, 110.0, 325.0), "to B's right site (C is mostly to its right) in slide space, B moved with the group: {p1:?}"); + s.execute("shape.move", &json!({"ids": [g], "dx": 50})).unwrap(); + assert_eq!(glue(&s, l), (Some(c), Some(b))); + let (_, p1) = line_ends(&s, l); + assert!(near(p1, 160.0, 325.0), "{p1:?}"); +} + #[test] fn highlight_with_an_invalid_colour_is_rejected_and_keeps_the_highlight() { let mut s = session(); From e075808108f867fb738ae9a661416f451a23efcd Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:01:35 -0500 Subject: [PATCH 4/4] Imports after upstream replaced wildcard imports (#1444) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/shape/freeform.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/shape/freeform.rs b/crates/engine/src/cmd/shape/freeform.rs index c857a9b45..f7ff83d85 100644 --- a/crates/engine/src/cmd/shape/freeform.rs +++ b/crates/engine/src/cmd/shape/freeform.rs @@ -1,4 +1,4 @@ -use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad, bool_or, id_param, json, str_param, usize_param}; +use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad, bool_or, id_param, json, str_param, usize_param, xfrm_of}; pub(super) fn sites(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;