From a072e29016e8d878a16bf1a2a7cd2dd71992ea18 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 06:10:37 +0700 Subject: [PATCH 1/2] Fix #1512: a round corner next to a curved side no longer sticks out of the corner Live Corners cut a corner between a curve and another side with the handles of the arc between straight sides. The curve has turned by the time the cut meets it, so those handles reached past where the tangents at the cut's ends cross, and from a middling radius on the cut bulged out of the shape or looped. Each handle is now the share an arc takes of its tangent up to that crossing. Corners between straight sides are cut as before. --- crates/geom/src/corners.rs | 55 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) 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() { From 61cc3f6040d54629707dc3aaf5cbb0f4470cfcd3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 06:11:03 +0700 Subject: [PATCH 2/2] Fix #1512: the Smooth tool smooths the stretch of path under the drag A drag near an anchor refitted the whole segments on either side of it, so a short drag over a small bump on a long side turned the side into a curve from end to end. The segments leading into the anchors under the drag are now split where they enter the tool's reach, and only what is in reach is refitted. --- crates/engine/src/cmd/draw2.rs | 60 ++++++++++++++++++++++++++++---- crates/engine/src/tests_draw2.rs | 23 ++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) 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();