From 8ce22e71eba827b1bd3c1ce42dda64b60fa11b96 Mon Sep 17 00:00:00 2001 From: Kindmark <39938776+Kindmark@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:57:11 -0600 Subject: [PATCH] Contour: keep a Contour's corners when dragged again; ignore a no-distance drag Two fixes from review of #5694, each with a test that fails without it: - Dragging a selected Contour sent the tool's own corner options, so a Round or Square Contour came back Sharp and lost its miter limit, and the Contour bar (which reads the effect) disagreed with the shape. The press now reads the Contour's own corners and miter limit and every preview of that drag carries them; a new Contour still takes the tool's options. - A drag that never moved the distance (along the edge, or rounded to nothing by Shift) previewed offset 0 and committed an unchanged copy and an undo step. Nothing is previewed until the distance leaves where it started. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/tests/contour.rs | 34 ++++++++++++++++++++++++++++++ crates/tools/src/contour.rs | 34 +++++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/tests/contour.rs b/crates/engine/src/tests/contour.rs index 8ec0d7f45..164370c75 100644 --- a/crates/engine/src/tests/contour.rs +++ b/crates/engine/src/tests/contour.rs @@ -299,6 +299,40 @@ mod tool { assert_eq!(effects_of(&s, contour)[0].1["joins"], json!("round")); } + /// A Contour keeps its own corner style and miter limit when it is dragged again, whatever the + /// tool's options are by then (they said Sharp when the Contour was made Square). + #[test] + fn dragging_a_contour_again_keeps_its_corners_and_miter_limit() { + let (mut s, _) = ready(); + s.execute("object.contour", &json!({"offset": 5, "corners": "square", "miterLimit": 7})).unwrap(); + let contour = s.doc().unwrap().selection.objects[0]; + s.set_tool_option("corners", &json!("sharp")); + at(&mut s, PointerKind::Down, 150.0, 120.0, Mods::default()); + at(&mut s, PointerKind::Drag, 158.0, 120.0, Mods::default()); + at(&mut s, PointerKind::Up, 158.0, 120.0, Mods::default()); + let fx = effects_of(&s, contour); + assert_eq!(fx.len(), 1); + assert!((offset_of(&s, contour) - 13.0).abs() < 1e-6, "{}", offset_of(&s, contour)); + assert_eq!(fx[0].1["joins"], json!("bevel"), "{:?}", fx[0].1); + assert_eq!(fx[0].1["miterLimit"], json!(7.0), "{:?}", fx[0].1); + } + + /// A drag that never moves the distance (along the edge, or rounded back to nothing by Shift) + /// leaves no unchanged copy and no undo step. + #[test] + fn a_drag_along_the_edge_changes_nothing() { + let (mut s, _) = ready(); + let (before, nodes) = (undo_depth(&s), node_count(&s)); + let shift = Mods { shift: true, ..Mods::default() }; + for (mods, to) in [(Mods::default(), (150.0, 135.0)), (shift, (150.3, 120.0))] { + at(&mut s, PointerKind::Down, 150.0, 120.0, mods); + at(&mut s, PointerKind::Drag, to.0, to.1, mods); + at(&mut s, PointerKind::Up, to.0, to.1, mods); + assert_eq!((undo_depth(&s), node_count(&s)), (before, nodes), "{to:?}"); + assert!(!s.in_interaction()); + } + } + /// The outline a Contour was made from shows as a dashed guide over a solid halo (so it reads /// over any fill); a shape that is not a Contour yet has none. #[test] diff --git a/crates/tools/src/contour.rs b/crates/tools/src/contour.rs index a23329b7b..cf450752c 100644 --- a/crates/tools/src/contour.rs +++ b/crates/tools/src/contour.rs @@ -51,6 +51,22 @@ fn contour_offset(n: &Node) -> Option { Some(e.params.get("offset").and_then(Value::as_f64).unwrap_or(0.0)) } +/// The corner style and miter limit of a Contour's Offset Path effect, as `object.contour` takes +/// them. +fn contour_style(n: &Node) -> Option { + let e = n.appearance.effects.iter().rev().find(|e| e.id == EFFECT)?; + let corners = match e.params.get("joins").and_then(Value::as_str) { + Some("round") => "round", + Some("bevel") => "square", + _ => "sharp", + }; + let mut style = json!({ "corners": corners }); + if let Some(m) = e.params.get("miterLimit").and_then(Value::as_f64) { + style["miterLimit"] = json!(m); + } + Some(style) +} + /// How far `p` is from the nearest of `bases`' edges: positive outside, negative inside. fn signed_distance(bases: &[Base], p: Point) -> Option { let mut nearest = f64::INFINITY; @@ -69,6 +85,9 @@ struct Drag { pressed_at: f64, /// The Contour's distance at the press (0 for a shape that is not one yet). start: f64, + /// The corner style and miter limit every preview carries: the pressed Contour's own, else the + /// tool's options. + style: Value, /// The distance last previewed. last: Option, /// Where the pointer is, for the distance label. @@ -108,8 +127,9 @@ impl Corners { } impl ContourTool { - fn params(&self, offset: f64) -> Value { - let mut p = json!({"offset": offset, "corners": self.corners.id()}); + /// The corner style and miter limit of a new Contour: the tool's options. + fn style(&self) -> Value { + let mut p = json!({ "corners": self.corners.id() }); if let Some(m) = self.miter_limit { p["miterLimit"] = json!(m); } @@ -142,21 +162,25 @@ impl Tool for ContourTool { PointerKind::Down => { let shapes = selected_shapes(cx); let start = shapes.first().and_then(|n| contour_offset(n)).unwrap_or(0.0); + // A Contour keeps its own corners; a new one takes the tool's. + let style = shapes.first().and_then(|n| contour_style(n)).unwrap_or_else(|| self.style()); let bases: Vec = shapes.into_iter().filter_map(base_of).collect(); let Some(pressed_at) = signed_distance(&bases, ev.pos) else { return vec![] }; - self.drag = Some(Drag { bases, pressed_at, start, last: None, pos: ev.pos }); + self.drag = Some(Drag { bases, pressed_at, start, style, last: None, pos: ev.pos }); vec![Action::Begin("Contour".into())] } PointerKind::Drag => { - let mut params = self.params(0.0); let Some(drag) = self.drag.as_mut() else { return vec![] }; drag.pos = ev.pos; let Some(now) = signed_distance(&drag.bases, ev.pos) else { return vec![] }; let offset = round_distance(drag.start + now - drag.pressed_at, ev.mods); - if !offset.is_finite() || drag.last == Some(offset) { + // Nothing is previewed until the distance has left where it started: a drag along + // the edge (or one Shift rounds back) would otherwise leave an unchanged copy. + if !offset.is_finite() || drag.last == Some(offset) || (drag.last.is_none() && offset == drag.start) { return vec![]; } drag.last = Some(offset); + let mut params = drag.style.clone(); params["offset"] = json!(offset); vec![Action::Preview("object.contour".into(), params)] }