Merge pull request #2484 from vangel76/fix/2332-corner-radius-to-neighbour

Live Corners: a corner rounds until its cut reaches its neighbours (#2332)
This commit is contained in:
BFlat
2026-10-17 13:45:25 +07:00
committed by GitHub
7 changed files with 186 additions and 47 deletions
+11 -2
View File
@@ -5,7 +5,7 @@
use std::borrow::Cow;
use std::collections::BTreeSet;
use vectorcraft_geom::corners::{Corner, cut_corners, path_corners};
use vectorcraft_geom::corners::{Corner, cut_corners, fitted_radii, path_corners};
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, PathData, Rect};
@@ -23,6 +23,8 @@ pub struct LiveCorners<'a> {
/// The corners that can be cut, in anchor order ([`path_corners`] of `base`).
pub corners: Vec<Corner>,
radii: Vec<f64>,
/// The radius each of `corners` is drawn with ([`fitted_radii`]).
drawn: Vec<f64>,
kinds: Vec<CornerKind>,
/// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from.
sources: Vec<Vec<usize>>,
@@ -54,7 +56,9 @@ impl<'a> LiveCorners<'a> {
})
.collect()
};
Some(Self { corners: path_corners(&base), base, xf, radii, kinds, sources })
let corners = path_corners(&base);
let drawn = fitted_radii(&corners, &radii);
Some(Self { corners, base, xf, radii, drawn, kinds, sources })
}
/// The Live Corners of a path object.
@@ -73,6 +77,11 @@ impl<'a> LiveCorners<'a> {
self.radii.get(index).copied().unwrap_or(0.0)
}
/// The radius corner `index` is drawn with, in document units: its own, no larger than its
/// neighbours leave room for (0 for an anchor that is no corner).
pub fn drawn_radius(&self, index: usize) -> f64 {
self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.drawn.get(i)).copied().unwrap_or(0.0)
}
/// The kind corner `index` is cut with.
pub fn kind(&self, index: usize) -> CornerKind {
self.kinds.get(index).copied().unwrap_or_default()
+99 -19
View File
@@ -76,6 +76,10 @@ pub struct Corner {
/// The sides themselves, leaving the corner, in the same order.
pub side_u: Side,
pub side_v: Side,
/// The anchor indices (in the path) of the anchors at the other ends of the sides, in the
/// same order.
pub before: usize,
pub after: usize,
}
impl Corner {
@@ -84,8 +88,10 @@ impl Corner {
/// sides leave it straight on.
fn of(sp: &SubPath, i: usize, index: usize) -> Option<Self> {
let n = sp.anchors.len();
let (prev, next) = if sp.closed { ((i + n).checked_sub(1)? % n, (i + 1) % n) } else { (i.checked_sub(1)?, i + 1) };
let (a, prev, next) = (sp.anchors.get(i)?, sp.anchors.get(prev)?, sp.anchors.get(next)?);
let (prev_i, next_i) = if sp.closed { ((i + n).checked_sub(1)? % n, (i + 1) % n) } else { (i.checked_sub(1)?, i + 1) };
let (a, prev, next) = (sp.anchors.get(i)?, sp.anchors.get(prev_i)?, sp.anchors.get(next_i)?);
let first = index.checked_sub(i)?;
let (before, after) = (first + prev_i, first + next_i);
if a.kind == AnchorKind::Smooth {
return None;
}
@@ -96,7 +102,7 @@ impl Corner {
if !(lu > EPS && lv > EPS && lu.is_finite() && lv.is_finite()) {
return None;
}
let c = Self { index, at: a.p, u, v, lu, lv, side_u, side_v };
let c = Self { index, at: a.p, u, v, lu, lv, side_u, side_v, before, after };
(c.sin() > MIN_SINE).then_some(c)
}
@@ -120,17 +126,43 @@ impl Corner {
r * (1.0 + self.cos()) / self.sin()
}
/// The largest radius the corner draws: its cut then reaches halfway along the shorter side,
/// so neighbouring corners at their largest meet without overlapping (half the shorter side
/// at a rectangle's corners).
pub fn max_radius(&self) -> f64 {
self.lu.min(self.lv) / 2.0 * self.sin() / (1.0 + self.cos())
/// The radius of a round cut that meets each side `s` from the corner (the inverse of
/// [`Corner::setback`]).
pub fn radius_at_setback(&self, s: f64) -> f64 {
s * self.sin() / (1.0 + self.cos())
}
/// The radius the corner is drawn with: `radius`, no larger than [`Corner::max_radius`], 0 when
/// negative or not a number.
pub fn fitted(&self, radius: f64) -> f64 {
radius.max(0.0).min(self.max_radius())
/// The largest radius the corner draws whatever its neighbours do: its cut then reaches
/// halfway along the shorter side, so neighbouring corners at their largest meet without
/// overlapping (half the shorter side at a rectangle's corners).
pub fn max_radius(&self) -> f64 {
self.radius_at_setback(self.lu.min(self.lv) / 2.0)
}
/// The largest radius the corner draws when the cuts of the corners at the other ends of its
/// sides reach `before` along side u and `after` along side v: its cut reaches halfway along
/// a side, or further, up to where the other end's cut begins, so all of a side whose other
/// end isn't cut (#2332).
pub fn max_radius_beside(&self, before: f64, after: f64) -> f64 {
let room = |len: f64, other: f64| (len - other.max(0.0)).max(len / 2.0);
self.radius_at_setback(room(self.lu, before).min(room(self.lv, after)))
}
/// The largest radius the corner draws when it and the corners for which `edited` holds are
/// set to one radius together, the other corners of `corners` keeping their `radii` (by
/// anchor index). A side shared with an edited corner takes both cuts as far as they fit
/// side by side, or halfway each; on a side to any other corner the cut reaches up to where
/// that corner's cut begins ([`Corner::max_radius_beside`]).
pub fn max_shared_radius(&self, corners: &[Corner], radii: &[f64], edited: impl Fn(usize) -> bool) -> f64 {
let side = |len: f64, other: usize| match find(corners, other) {
Some(c) if other == self.index || edited(other) => {
let both = self.setback(1.0) + c.setback(1.0);
let apart = if both > EPS { len / both } else { f64::INFINITY };
apart.max(self.radius_at_setback(len / 2.0))
}
_ => self.radius_at_setback((len - requested_setback(corners, radii, other)).max(len / 2.0)),
};
side(self.lu, self.before).min(side(self.lv, self.after))
}
/// The point `k` along the bisector, measured in (u + v): the centre of a round cut of radius
@@ -237,15 +269,43 @@ pub fn path_corners(path: &PathData) -> Vec<Corner> {
out
}
/// The corner at anchor `index` among `corners` (in anchor order).
fn find(corners: &[Corner], index: usize) -> Option<&Corner> {
corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| corners.get(i))
}
/// How far along each side the cut of the corner at anchor `index` asks to reach, with the radius
/// in `radii` there (0 for an anchor that is no corner, or a radius that is negative or not a
/// number).
fn requested_setback(corners: &[Corner], radii: &[f64], index: usize) -> f64 {
find(corners, index).map_or(0.0, |c| c.setback(radii.get(index).copied().unwrap_or(0.0).max(0.0)))
}
/// The radius each of `corners` (in anchor order) is drawn with, given the radii by anchor index:
/// its own, no larger than leaves room for the cuts of its neighbours ([`Corner::max_radius_beside`]
/// with the setbacks their radii ask for), so a corner whose neighbours are square rounds until
/// its cut reaches them (#2332) and two neighbours never cut past each other.
pub fn fitted_radii(corners: &[Corner], radii: &[f64]) -> Vec<f64> {
corners
.iter()
.map(|c| {
let own = radii.get(c.index).copied().unwrap_or(0.0).max(0.0);
let max = c.max_radius_beside(requested_setback(corners, radii, c.before), requested_setback(corners, radii, c.after));
own.min(max)
})
.collect()
}
/// `path` with each of its corners ([`path_corners`]) cut by the radius in `radii` and the kind in
/// `kinds` at its anchor index (missing: 0 and round), each radius no larger than fits
/// ([`Corner::fitted`]). A cut corner becomes two anchors, and a curved side between corners is
/// ([`fitted_radii`]). A cut corner becomes two anchors, and a curved side between corners is
/// trimmed to what is left of it; a closed subpath whose first corner is cut starts where that
/// cut ends, so the cut closes it. Also returns, per subpath, the anchor index in `path` each
/// anchor comes from.
pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (PathData, Vec<Vec<usize>>) {
let corners = path_corners(path);
let mut corners = corners.iter().peekable();
let fitted = fitted_radii(&corners, radii);
let mut corners = corners.iter().zip(fitted).peekable();
let (mut subpaths, mut sources) = (Vec::with_capacity(path.subpaths.len()), Vec::with_capacity(path.subpaths.len()));
let mut first = 0;
for sp in &path.subpaths {
@@ -254,8 +314,7 @@ pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (Pat
let cuts: Vec<Option<Cut>> = (0..n)
.map(|i| {
let index = first + i;
let c = corners.next_if(|c| c.index == index)?;
let radius = c.fitted(radii.get(index).copied().unwrap_or(0.0));
let (c, radius) = corners.next_if(|(c, _)| c.index == index)?;
(radius > EPS).then(|| c.cut(radius, kinds.get(index).copied().unwrap_or_default()))
})
.collect();
@@ -357,9 +416,6 @@ mod tests {
for c in &corners {
let max = c.max_radius();
assert!((c.setback(max) - c.lu.min(c.lv) / 2.0).abs() < 1e-9);
assert_eq!(c.fitted(1e6), max);
assert_eq!(c.fitted(-3.0), 0.0);
assert_eq!(c.fitted(f64::NAN), 0.0);
}
// The right angle at (0, 0) takes up to 20, half its shorter side.
assert!((corners[0].max_radius() - 20.0).abs() < 1e-9);
@@ -470,4 +526,28 @@ mod tests {
assert!((bez.eval(t).distance(c.at) - r).abs() < r * 1e-3);
}
}
/// #2332: a corner rounds until its cut reaches its neighbours' (all of a side whose other
/// end is square); neighbours that both ask for more than half a side meet halfway.
#[test]
fn a_corner_rounds_up_to_its_neighbours_cuts() {
let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
let corners = path_corners(&square);
let drawn = |radii: [f64; 4]| fitted_radii(&corners, &radii);
assert_eq!(drawn([0.0, 500.0, 0.0, 0.0]), [0.0, 100.0, 0.0, 0.0]);
assert_eq!(drawn([30.0, 500.0, 0.0, 0.0]), [30.0, 70.0, 0.0, 0.0]);
assert_eq!(drawn([80.0, 500.0, 0.0, 0.0]), [50.0, 50.0, 0.0, 0.0]);
assert_eq!(drawn([500.0; 4]), [50.0; 4]);
assert_eq!(drawn([f64::NAN, 120.0, -1.0, 0.0]), [0.0, 100.0, 0.0, 0.0]);
// The lone cut is a quarter circle from one neighbour to the other.
let (cut, _) = cut_corners(&square, &[0.0, 500.0, 0.0, 0.0], &[]);
let ends: Vec<Point> = cut.anchors().map(|(_, _, a)| a.p).collect();
assert_eq!(ends, [Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(100.0, 100.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)]);
// Dragged together with its neighbour, a corner meets it halfway; beside a square one,
// it goes all the way.
let tr = corners[1];
assert!((tr.max_shared_radius(&corners, &[0.0; 4], |k| k == 0) - 50.0).abs() < 1e-9);
assert!((tr.max_shared_radius(&corners, &[0.0; 4], |_| false) - 100.0).abs() < 1e-9);
assert!((tr.max_shared_radius(&corners, &[0.0, 0.0, 40.0, 0.0], |_| false) - 60.0).abs() < 1e-9);
}
}
+18 -9
View File
@@ -52,15 +52,23 @@ pub fn max_corner_radius(w: f64, h: f64) -> f64 {
w.abs().min(h.abs()) / 2.0
}
/// The radius a corner of a `w` × `h` rectangle is drawn with: `radius`, no larger than
/// [`max_corner_radius`] (the same limit for every corner, so opposite corners match), 0 when
/// negative or not a number.
pub fn fitted_corner_radius(w: f64, h: f64, radius: f64) -> f64 {
radius.max(0.0).min(max_corner_radius(w, h))
/// The radii the corners of a `w` × `h` rectangle (top-left, top-right, bottom-right,
/// bottom-left) are drawn with: each its own, no larger than leaves room for its neighbours'
/// cuts ([`crate::corners::fitted_radii`]): half the shorter side when they are all as large, up
/// to a whole side when its neighbours are square (#2332); 0 when negative or not a number.
pub fn fitted_corner_radii(w: f64, h: f64, radii: [f64; 4]) -> [f64; 4] {
let corners = crate::corners::path_corners(&rectangle(Rect::new(0.0, 0.0, w, h)));
let mut out = [0.0; 4];
for (c, r) in corners.iter().zip(crate::corners::fitted_radii(&corners, &radii)) {
if let Some(o) = out.get_mut(c.index) {
*o = r;
}
}
out
}
/// Rectangle whose corners (top-left, top-right, bottom-right, bottom-left, y down) each have
/// their own radius and kind, every radius clamped to half the shorter side. A corner without a
/// their own radius and kind, every radius no larger than [`fitted_corner_radii`] leaves. A corner without a
/// radius is one anchor, the others two (where the cut meets each side). Clockwise from the
/// top-left corner, its anchor on the top side first. Cut as any path's corners are
/// ([`crate::corners::cut_corners`]).
@@ -379,9 +387,11 @@ mod tests {
let chamfer = rectangle_with_corners(r, radii, [CornerKind::Chamfer; 4]);
assert!((chamfer.subpaths[0].area() - (6000.0 - 50.0)).abs() < 1e-9);
assert!(chamfer.subpaths[0].anchors.iter().all(|a| !a.has_in() && !a.has_out()), "straight");
// Radii clamp to half the shorter side; negative and NaN radii are none.
// Radii clamp to what their neighbours leave, the whole shorter side when they are
// square (#2332): the cut reaches the bottom-left corner. Negative and NaN radii are none.
let p = rectangle_with_corners(r, [500.0, -5.0, f64::NAN, 0.0], [CornerKind::Round; 4]);
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(30.0, 0.0));
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(60.0, 0.0));
assert_eq!(p.subpaths[0].anchors.last().map(|a| a.p), Some(Point::new(0.0, 60.0)));
assert_eq!(p.anchor_count(), 5);
assert_eq!(CornerKind::ALL.map(CornerKind::next), [CornerKind::InvertedRound, CornerKind::Chamfer, CornerKind::Round]);
}
@@ -392,7 +402,6 @@ mod tests {
fn corners_of_a_non_square_rectangle_are_alike() {
let r = Rect::new(0.0, 0.0, 200.0, 80.0);
assert_eq!(max_corner_radius(-200.0, 80.0), 40.0);
assert_eq!([30.0, 60.0, -1.0, f64::NAN].map(|x| fitted_corner_radius(200.0, 80.0, x)), [30.0, 40.0, 0.0, 0.0]);
for (radius, drawn) in [(30.0, 30.0), (60.0, 40.0)] {
for kinds in [[CornerKind::Round; 4], [CornerKind::Chamfer, CornerKind::InvertedRound, CornerKind::Round, CornerKind::Chamfer]] {
let a: Vec<Point> = rectangle_with_corners(r, [radius; 4], kinds).subpaths[0].anchors.iter().map(|a| a.p).collect();
+3 -3
View File
@@ -622,9 +622,9 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option<Corners> {
Some(LiveShape::Rectangle { w, h, radii, kinds, xf })
if upright(xf) && radii.iter().zip(kinds).all(|(r, k)| *r <= 0.0 || *k == CornerKind::Round) =>
{
// The radii as drawn, none past half the shorter side (CSS draws one further when its
// neighbours leave room).
let radii = radii.map(|r| shapes::fitted_corner_radius(*w, *h, r));
// The radii as drawn: no two neighbours' cuts add up to more than their side, so CSS
// draws them as they are.
let radii = shapes::fitted_corner_radii(*w, *h, *radii);
if radii.iter().all(|r| *r <= 0.0) {
Some(Corners::Square)
} else if radii.iter().all(|r| (r - radii[0]).abs() < 1e-9) {
+3 -3
View File
@@ -84,13 +84,13 @@ fn live_corners_give_per_corner_radii_and_only_round_ones() {
kinds[1] = CornerKind::InvertedRound;
}
assert!(prop(&one(&d, id, &CssOptions::default()), "border-radius").is_none(), "CSS can't cut a corner in");
// A radius past half the shorter side is drawn at half of it (#442), and exported so: CSS
// would draw it larger, its neighbours leaving room.
// A radius is drawn no larger than its neighbours leave room for, past half the shorter
// side when they are small (#2332), and exported as drawn: 50 less the 4 below it.
if let Some(LiveShape::Rectangle { radii, kinds, .. }) = d.node_mut(id).and_then(|n| n.live_shape_mut()) {
*radii = [60.0, 8.0, 0.0, 4.0];
*kinds = Default::default();
}
assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("25px 8px 0 4px"));
assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("46px 8px 0 4px"));
}
#[test]
+49 -9
View File
@@ -29,8 +29,10 @@ const MIN_SIDE_PX: f64 = 3.0 * MIN_INSET_PX;
struct Widget {
/// The corner, in the corners' own space.
corner: Corner,
/// The radius it is set to (document units) and its kind.
/// The radius it is set to (document units), the one it is drawn with (no larger than its
/// neighbours leave room for) and its kind.
radius: f64,
drawn: f64,
kind: CornerKind,
/// The angle between its sides in the document, in degrees.
angle: f64,
@@ -76,9 +78,9 @@ impl PathWidgets {
.map(|c| {
// On the bisector at the centre of the arc as drawn, at least the minimum inset
// in and at most half the shorter side.
let radius = lc.radius(c.index);
let k = (c.fitted(radius) / c.sin()).max(MIN_INSET_PX / (scale * (1.0 + c.cos()).sqrt())).min(c.lu.min(c.lv) / 2.0);
Widget { corner: *c, radius, kind: lc.kind(c.index), angle: angle_in(lc.xf, c), at: lc.xf * c.on_bisector(k) }
let (radius, drawn) = (lc.radius(c.index), lc.drawn_radius(c.index));
let k = (drawn / c.sin()).max(MIN_INSET_PX / (scale * (1.0 + c.cos()).sqrt())).min(c.lu.min(c.lv) / 2.0);
Widget { corner: *c, radius, drawn, kind: lc.kind(c.index), angle: angle_in(lc.xf, c), at: lc.xf * c.on_bisector(k) }
})
.collect();
let all = widgets.len() == lc.corners.len();
@@ -217,6 +219,8 @@ pub fn double_click(cx: &ToolContext, p: Point, any_path: bool) -> Option<Action
#[derive(Clone, Debug)]
struct Outline {
base: PathData,
/// The corners of `base`.
corners: Vec<Corner>,
radii: Vec<f64>,
kinds: Vec<CornerKind>,
xf: Affine,
@@ -278,7 +282,7 @@ impl CornerDrag {
let lc = cx.doc.node(w.id).and_then(LiveCorners::of)?;
let n = lc.base.anchor_count();
let (radii, kinds) = ((0..n).map(|k| lc.radius(k)).collect(), (0..n).map(|k| lc.kind(k)).collect());
Some(Outline { base: lc.base.into_owned(), radii, kinds, xf: w.xf })
Some(Outline { corners: lc.corners.clone(), base: lc.base.into_owned(), radii, kinds, xf: w.xf })
})
.collect::<Option<Vec<_>>>()?;
Some(Self { widgets, widget, outlines, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p, click })
@@ -293,15 +297,23 @@ impl CornerDrag {
return (self.radius, self.at_limit);
};
let inv = xf.inverse();
let max = w.corner.max_radius();
let r = w.corner.fitted(w.radius) + w.corner.radius_change(inv * p - inv * self.start);
let max = self.max_radius(path, &w.corner);
let r = w.drawn + w.corner.radius_change(inv * p - inv * self.start);
(r.min(max).max(0.0), max > 0.0 && r >= max)
}
/// The largest radius `corner` of shown path `path` draws while the drag sets every shown
/// corner of that path to one radius: up to the cuts of its neighbours, as far as its own
/// sides when they are square (#2332).
fn max_radius(&self, path: usize, corner: &Corner) -> f64 {
let (Some(shown), Some(o)) = (self.widgets.paths.get(path), self.outlines.get(path)) else { return corner.max_radius() };
corner.max_shared_radius(&o.corners, &o.radii, |k| shown.widgets.iter().any(|w| w.corner.index == k))
}
/// The cuts of the edited corners that are at their largest radius, in document coordinates.
fn corners_at_limit(&self) -> BezPath {
let mut out = BezPath::new();
for (shown, o) in self.widgets.paths.iter().zip(&self.outlines) {
for (n, (shown, o)) in self.widgets.paths.iter().zip(&self.outlines).enumerate() {
let shown = &shown.widgets;
let mut radii = o.radii.clone();
for w in shown {
@@ -309,7 +321,7 @@ impl CornerDrag {
*r = self.radius;
}
}
let full = |k: &usize| shown.iter().any(|w| w.corner.index == *k && self.radius >= w.corner.max_radius() - 1e-9);
let full = |k: &usize| shown.iter().any(|w| w.corner.index == *k && self.radius >= self.max_radius(n, &w.corner) - 1e-9);
let (path, sources) = cut_corners(&o.base, &radii, &o.kinds);
for (sp, from) in path.transformed(o.xf).subpaths.iter().zip(&sources) {
// A corner's cut is the segment between its two anchors (the last one, for a
@@ -611,6 +623,34 @@ mod tests {
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 175.0, 176.0)), vec![Action::Commit]);
}
/// #2332: one corner rounded alone goes past half the side, until its cut reaches the
/// neighbouring anchors (the whole side of a square), or a rounded neighbour's cut.
#[test]
fn a_corner_rounded_alone_rounds_up_to_its_neighbours() {
for (top_right, max) in [(0.0, 100.0), (30.0, 70.0)] {
let live = LiveShape::Rectangle {
w: 100.0,
h: 100.0,
radii: [0.0, top_right, 0.0, 0.0],
kinds: Default::default(),
xf: Affine::translate((100.0, 100.0)),
};
let (d, id) = doc_with(live.to_path(), Some(live));
// Direct Selection picked the bottom-right anchor.
let mut s = selected(id);
s.anchors.insert(id, [(0, if top_right > 0.0 { 3 } else { 2 })].into());
let p = paint();
let cx = cx(&d, &s, &p);
assert_eq!(CornerWidgets::of(&d, &s, 1.0, true).unwrap().corners(), [2]);
let mut t = crate::create("directSelection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 190.0));
assert!((radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0))[1]) - 60.0).abs() < 1e-9);
assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })), "60 is below the limit");
assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 0.0, 0.0))[0]), max);
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })), "the limit shows");
}
}
#[test]
fn alt_click_cycles_the_kind_and_double_click_opens_the_dialog() {
let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0)));
+3 -2
View File
File diff suppressed because one or more lines are too long