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 authored and GitHub committed 2026-10-17 13:45:25 +07:00
commit 26644850fc
7 files changed
+186 -47

No files matched your search

+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
@@ -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. |