mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-22 19:08:39 +00:00
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:
7 files changed
+186
-47
No files matched your search
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS saveLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restoreLine truncated
|
||||
|
||||
How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's
|
||||
trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to,
|
||||
@@ -83,7 +83,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Cut path at selected anchors, Remove Anchor Points, Delete removes segments | done | Open-path anchor Delete removes adjoining segments without bridging the gap; targeted core and full-frame Delete/Backspace regressions pass for survivor selection and Undo/Redo. Explicit Remove Anchor Points keeps its reconnect/refit behavior; #1278: segment Delete preserves neighboring edges and endpoint anchors; a second Delete removes the remainder; explicit anchor Delete retains its behavior; #1674: Delete removes every selected anchor of a closed path and objects selected whole along with them, and keeps the remainder selected; #2241: Remove Anchor Points then selects the next anchor along the path (the one before at an open end), so it can be run again without picking each anchor |
|
||||
| Direct Selection and Group Selection pick only inside the isolated object in isolation mode | done | #1666 |
|
||||
| Escape cancels a Direct Selection drag | done | #1803 (#1743) |
|
||||
| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512) |
|
||||
| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) |
|
||||
| Corners between curved sides (#1144), Relative rounding | partial | Relative rounding missing |
|
||||
| Pie widgets on a live ellipse: drag one to set Pie Start or End Angle, double-click one to make the ellipse whole | done | Selection tool, with the bounding box showing |
|
||||
| Isolation mode by double-click (groups and single objects), breadcrumbs, Esc to exit | done | Single paths, compound paths, images, meshes and placed files isolate too ([helpx: Isolate objects](https://helpx.adobe.com/africa/illustrator/desktop/manage-objects/select-objects/isolate-objects.html)) |
|
||||
@@ -234,6 +234,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, #
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-16 | minor | Live Corners: a corner rounds until its cut reaches the neighbouring anchors or their cuts, so one corner of a 100 pt square alone reaches a 100 pt radius instead of 50 (#2332); score unchanged (estimated) |
|
||||
| 2026-10-16 | minor | Correction: quick Image Trace presets trace at once and leave the panel as it was (the 2026-10-14 row below said a quick preset reveals it; the later change in `baadbe2c6` made the button the way in); the Image Trace button in the Control bar or Properties opens the controls, as the public documentation describes. Wording only; scores and hours unchanged. |
|
||||
| 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) |
|
||||
| 2026-10-16 | minor | Anchor Point tool: a click on one handle end retracts that handle only; the anchor becomes a corner and keeps the other handle (#2267). Handle drags start past the same 2 px as anchor drags. Scores and hours unchanged. |
|
||||
|
||||
Reference in new issue
Block a user