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)] }