Merge pull request #1547 from storytold/fix/1512-corners-smooth

Fix #1512: round corners next to curves stay inside the corner, and the Smooth tool smooths what is under the drag
This commit is contained in:
BFlat 2026-10-13 07:14:14 +07:00 committed by GitHub
commit 0f4de5b98e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 130 additions and 8 deletions

View File

@ -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<Anchor> {
/// 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<Anchor> {
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<Anchor> {
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
}

View File

@ -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();

View File

@ -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() {