From 8875b3fbca7ab7557ec51114ee3fcdfd1c992701 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:07:11 +0200 Subject: [PATCH 1/2] Live Corners: a corner rounds until its cut reaches its neighbours (#2332) A corner's radius was capped at half its shorter side whatever its neighbours did, so one corner of a 100 pt square rounded alone stopped at 50 pt. Each corner now rounds until its cut reaches the cuts its neighbours ask for: the whole side when the neighbour is square, halfway when both ask for more. The corner widget drag, the red limit outline and CSS border-radius export follow the same limit. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/corners.rs | 13 +++- crates/geom/src/corners.rs | 117 ++++++++++++++++++++++++++++++---- crates/geom/src/shapes.rs | 23 ++++++- crates/svg/src/css.rs | 6 +- crates/svg/tests/css_rules.rs | 6 +- crates/tools/src/corners.rs | 58 ++++++++++++++--- docs/ui-parity.md | 5 +- 7 files changed, 192 insertions(+), 36 deletions(-) diff --git a/crates/doc/src/corners.rs b/crates/doc/src/corners.rs index 986442b29..ccf884fda 100644 --- a/crates/doc/src/corners.rs +++ b/crates/doc/src/corners.rs @@ -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, radii: Vec, + /// The radius each of `corners` is drawn with ([`fitted_radii`]). + drawn: Vec, kinds: Vec, /// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from. sources: Vec>, @@ -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() diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 31e03026a..16120aa0e 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -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 { 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,19 +126,51 @@ 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. + /// 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 radius the corner is drawn with whatever its neighbours do: `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 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 /// `r` is at `k = r / sin`. pub fn on_bisector(&self, k: f64) -> Point { @@ -237,15 +275,43 @@ pub fn path_corners(path: &PathData) -> Vec { 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 { + 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>) { 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 +320,7 @@ pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (Pat let cuts: Vec> = (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(); @@ -470,4 +535,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 = 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); + } } diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index 5aa16b639..54a7ad933 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -59,8 +59,23 @@ 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 +394,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]); } diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index aef875a40..34e74269e 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -622,9 +622,9 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option { 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) { diff --git a/crates/svg/tests/css_rules.rs b/crates/svg/tests/css_rules.rs index 654a522db..2b4a203d9 100644 --- a/crates/svg/tests/css_rules.rs +++ b/crates/svg/tests/css_rules.rs @@ -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] diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index affb049f2..c3be27432 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -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, radii: Vec, kinds: Vec, 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::>>()?; 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))); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9e17ae045..9f963b248 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) +> **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 restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) 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. | From d5561d6e02bea8d98bea7e3ab36942d97fac5f07 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:54:10 +0700 Subject: [PATCH 2/2] Live Corners: drop the single-corner radius limits nothing uses since #2332 --- crates/geom/src/corners.rs | 9 --------- crates/geom/src/shapes.rs | 8 -------- 2 files changed, 17 deletions(-) diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 16120aa0e..46449f43a 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -148,12 +148,6 @@ impl Corner { self.radius_at_setback(room(self.lu, before).min(room(self.lv, after))) } - /// The radius the corner is drawn with whatever its neighbours do: `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 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 @@ -422,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); diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index 54a7ad933..e677d34ff 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -52,13 +52,6 @@ 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 @@ -409,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 = rectangle_with_corners(r, [radius; 4], kinds).subpaths[0].anchors.iter().map(|a| a.p).collect();