diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 5d955696..ea4439ce 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -3,7 +3,7 @@ use std::sync::Arc; -use kurbo::{ParamCurve, ParamCurveNearest}; +use kurbo::{CubicBez, ParamCurve, ParamCurveNearest}; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, PressureProfile, Selection}; @@ -1048,17 +1048,46 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Optio open.anchors.push(open.anchors[0]); m.push(false); open.closed = false; - let mut res = smooth_open(&open.anchors, &m, tol); + let mut res = smooth_open(&open.anchors, &m, near, tol); let l = res.pop()?; if let Some(r0) = res.first_mut() { r0.h_in = l.h_in; } return Some(SubPath::new(res, true)); } - Some(SubPath::new(smooth_open(&sp.anchors, &marked, tol), false)) + Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol), false)) } -fn smooth_open(anchors: &[Anchor], marked: &[bool], tol: f64) -> Vec { +/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it +/// enters the reach: the part outside, then the part in reach. `None` when no more than a sliver +/// is outside. +fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez, CubicBez)> { + const STEPS: u32 = 32; + let step = 1.0 / f64::from(STEPS); + // Back from the end in reach to the first sample outside, then halving to the crossing. + let outside = (1..=STEPS).map(|i| 1.0 - f64::from(i) * step).find(|t| !near(c.eval(*t)))?; + let (mut lo, mut hi) = (outside, outside + step); + for _ in 0..20 { + let mid = (lo + hi) / 2.0; + if near(c.eval(mid)) { + hi = mid; + } else { + lo = mid; + } + } + // A straight segment's parts stay straight, their handles on their ends. + let straight = c.p1 == c.p0 && c.p2 == c.p3; + let part = |range| { + let s = c.subsegment(range); + if straight { CubicBez::new(s.p0, s.p0, s.p3, s.p3) } else { s } + }; + (hi > 0.02).then(|| (part(0.0..hi), part(hi..1.0))) +} + +/// `anchors` with each run of `marked` ones refitted, together with the stretch of the segments +/// leading into the run that is in the drag's reach (`near`): a short drag smooths what is under +/// it, and the rest of a long segment next to it stays as it was (#1512). +fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64) -> Vec { let n = anchors.len(); let mut runs = vec![]; let mut i = 0; @@ -1079,8 +1108,27 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], tol: f64) -> Vec { if hi - lo < 2 { continue; } - let new = refit_run(&out[lo..=hi], tol); - out.splice(lo..=hi, new); + let mut piece = out[lo..=hi].to_vec(); + // The anchors outside the reach, kept with the part of their segment that is outside. + let (mut head, mut tail) = (None, None); + if hi > b + && let [.., inner, outer] = piece.as_mut_slice() + && let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_in, inner.h_out, inner.p), near) + { + inner.h_out = reach.p2; + tail = Some(Anchor { h_in: far.p1, ..*outer }); + *outer = Anchor::with_handles(far.p3, reach.p1, far.p2); + } + if lo < a + && let [outer, inner, ..] = piece.as_mut_slice() + && let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_out, inner.h_in, inner.p), near) + { + inner.h_in = reach.p2; + head = Some(Anchor { h_out: far.p1, ..*outer }); + *outer = Anchor::with_handles(far.p3, far.p2, reach.p1); + } + let new = refit_run(&piece, tol); + out.splice(lo..=hi, head.into_iter().chain(new).chain(tail)); } out } diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index f31d422b..d55370b1 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -412,6 +412,29 @@ fn smooth_tool_reduces_jagged_path() { assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0)); } +/// #1512: a short drag over a small bump smooths the stretch of path under it. The long straight +/// sides next to the bump stay straight lines from their far ends, which keep their place. +#[test] +fn a_short_smooth_drag_leaves_the_rest_of_long_segments_alone() { + let mut s = session(); + let anchors: Vec<_> = + [(50.0, 200.0), (190.0, 200.0), (200.0, 192.0), (210.0, 200.0), (350.0, 200.0)].map(|(x, y)| json!({"x": x, "y": y})).to_vec(); + let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let before = path(&s, id); + drag(&mut s, "smooth", &[(195.0, 194.0), (205.0, 194.0)], Mods::default()); + let p = path(&s, id); + assert_ne!(p, before, "the bump is smoothed"); + let a = &p.subpaths[0].anchors; + let (first, second, last) = (a[0], a[1], a[a.len() - 1]); + assert!(first.p == Point::new(50.0, 200.0) && !first.has_out() && !second.has_in(), "the left side is a line still: {first:?} {second:?}"); + assert!(last.p == Point::new(350.0, 200.0) && !last.has_in() && !a[a.len() - 2].has_out(), "and the right one: {last:?}"); + // Every other anchor is within the tool's reach of the drag (12 px either way). + for x in &a[1..a.len() - 1] { + assert!((182.0..=218.0).contains(&x.p.x) && (190.0..=201.0).contains(&x.p.y), "{x:?}"); + } +} + #[test] fn join_tool_joins_two_lines() { let mut s = session(); diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 209010d3..31e03026 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -155,8 +155,8 @@ impl Corner { let (h_out, h_in) = match kind { // An arc tangent to both sides, turning by 180° less the angle between them there. CornerKind::Round => { - let h = radius * arc_handle(-cos); - (start - du * h, end - dv * h) + let (hu, hv) = self.round_handles(radius, (start, du), (end, dv)); + (start - du * hu, end - dv * hv) } // An arc centred on the corner through both ends, spanning the angle. CornerKind::InvertedRound => { @@ -169,6 +169,33 @@ impl Corner { } } +impl Corner { + /// The handle lengths of a round cut of `radius` between `start` and `end` (each a point and + /// the direction its side runs on in there). Between straight sides they are the arc's. A + /// curved side has turned by the time the cut meets it, so the arc's handles would reach past + /// where the tangents at the cut's ends cross and the cut would stick out of the corner + /// (#1512): there each handle is the share an arc takes of its tangent, up to that crossing + /// (a third of the way to the other end where the tangents don't cross between them). + fn round_handles(&self, radius: f64, (start, du): (Point, Vec2), (end, dv): (Point, Vec2)) -> (f64, f64) { + let cos = du.dot(dv); + let arc = arc_handle(-cos); + if self.side_u.straight() && self.side_v.straight() { + return (radius * arc, radius * arc); + } + // start − du·a = end − dv·b is where the tangents cross. + let (cross, w) = (du.cross(dv), start - end); + let (a, b) = (w.cross(dv) / cross, w.cross(du) / cross); + if cross.abs() > MIN_SINE && a > EPS && b > EPS && a.is_finite() && b.is_finite() { + // An arc's handle over its tangent's length, radius · tan(turn / 2). + let share = arc * (1.0 - cos) / cross.abs(); + (a * share, b * share) + } else { + let third = start.distance(end) / 3.0; + (third, third) + } + } +} + /// A corner cut: the parameters where it meets its sides (from the corner), its two new anchors /// and the handles between them. #[derive(Clone, Copy, Debug)] @@ -401,6 +428,30 @@ mod tests { } } + /// A round cut next to a curve stays inside its corner, however large (#1512): its handles + /// stop short of where the tangents at its ends cross, so it doesn't stick out past the sides. + #[test] + fn a_round_cut_next_to_a_curve_stays_inside_its_corner() { + // A square whose top side arches up, meeting the upright sides at an angle. + let mut sp = + SubPath::polyline(&[Point::new(100.0, 150.0), Point::new(300.0, 150.0), Point::new(300.0, 300.0), Point::new(100.0, 300.0)], true); + sp.anchors[0].h_out = Point::new(120.0, 20.0); + sp.anchors[1].h_in = Point::new(280.0, 20.0); + let path = PathData::single(sp); + let bounds = path.bounds().unwrap().inflate(1e-6, 1e-6); + for radius in [10.0, 60.0, 200.0, 1e6] { + let (cut, _) = cut_corners(&path, &[radius, radius], &[]); + let b = cut.bounds().unwrap(); + assert!(bounds.union(b) == bounds, "radius {radius}: {b:?} is outside {bounds:?}"); + // Each cut's handles end before its tangents cross: the cut turns one way only. + let a = &cut.subpaths[0].anchors; + for (from, to) in [(a[a.len() - 1], a[0]), (a[1], a[2])] { + let (t0, t1, chord) = (from.h_out - from.p, to.p - to.h_in, to.p - from.p); + assert!(t0.cross(chord) * chord.cross(t1) > 0.0 && t0.cross(t1) * t0.cross(chord) > 0.0, "radius {radius}: {from:?} {to:?}"); + } + } + } + /// Inverted round and chamfer cuts end where the round one does, at any angle. #[test] fn kinds_share_their_ends() {