From ab0ddf0eae7020fc54985f9af6ba9cd10a63494f Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Mon, 19 Oct 2026 12:25:58 +0300 Subject: [PATCH] G1: Live Corners relative rounding (#1144) Store corner radii as fractions of each corner's max so rounding scales with the shape; expose via Corners dialog and object.setLiveShape relativeRadii. Co-authored-by: Cursor --- crates/doc/src/corners.rs | 179 ++++++++++++++++++++----- crates/doc/src/node.rs | 68 +++++++--- crates/engine/src/cmd/buildcmds.rs | 3 +- crates/engine/src/cmd/create.rs | 3 +- crates/engine/src/cmd/menucmds.rs | 2 +- crates/engine/src/cmd/object.rs | 22 ++- crates/engine/src/tests.rs | 2 +- crates/engine/src/tests_livecorners.rs | 25 +++- crates/svg/src/css.rs | 7 +- crates/svg/tests/css_rules.rs | 2 +- crates/tools/src/corners.rs | 7 +- crates/tools/src/select.rs | 1 + crates/ui-egui/src/dialogs/corners.rs | 48 ++++++- 13 files changed, 297 insertions(+), 72 deletions(-) diff --git a/crates/doc/src/corners.rs b/crates/doc/src/corners.rs index ccf884fda..e16403376 100644 --- a/crates/doc/src/corners.rs +++ b/crates/doc/src/corners.rs @@ -7,11 +7,66 @@ use std::collections::BTreeSet; use vectorcraft_geom::corners::{Corner, cut_corners, fitted_radii, path_corners}; use vectorcraft_geom::shapes::{self, CornerKind}; -use vectorcraft_geom::{Affine, PathData, Rect}; +use vectorcraft_geom::{Affine, PathData, Point, Rect}; use crate::node::{LiveShape, Node, NodeKind}; use crate::selection::AnchorRef; +/// Stored corner radii as document lengths for cutting and display (fractions when `relative`). +pub fn resolve_stored_radii(base: &PathData, stored: &[f64], relative: bool) -> Vec { + if !relative { + return stored.to_vec(); + } + let corners = path_corners(base); + let mut out = stored.to_vec(); + out.resize(base.anchor_count(), 0.0); + for c in corners { + if let Some(r) = out.get_mut(c.index) { + *r = (*r).clamp(0.0, 1.0) * c.max_radius(); + } + } + out +} + +/// Turn stored absolute radii into fractions of each corner's maximum, or the other way. +pub fn convert_radii_relative(live: &mut LiveShape, relative: bool) { + if live.radii_relative() == relative { + return; + } + let was_relative = live.radii_relative(); + let base = match live { + LiveShape::Rectangle { w, h, .. } => shapes::rectangle(Rect::new(0.0, 0.0, *w, *h)), + LiveShape::Polygon { .. } => live.polygon_outline(), + LiveShape::Path { base, .. } => base.clone(), + LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return, + }; + let corners = path_corners(&base); + let radii: &mut [f64] = match live { + LiveShape::Rectangle { radii, .. } => radii, + LiveShape::Polygon { radii, .. } => radii, + LiveShape::Path { radii, .. } => radii, + LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return, + }; + for c in corners { + if let Some(r) = radii.get(c.index) { + let max = c.max_radius(); + radii[c.index] = if relative { + if max > 1e-12 && !was_relative { (*r / max).clamp(0.0, 1.0) } else { *r } + } else if was_relative { + (*r).clamp(0.0, 1.0) * max + } else { + *r + }; + } + } + match live { + LiveShape::Rectangle { radii_relative: rel, .. } | LiveShape::Polygon { radii_relative: rel, .. } | LiveShape::Path { radii_relative: rel, .. } => { + *rel = relative; + } + LiveShape::Ellipse { .. } | LiveShape::Line { .. } => {} + } +} + /// The Live Corners of a path. #[derive(Clone, Debug)] pub struct LiveCorners<'a> { @@ -26,6 +81,8 @@ pub struct LiveCorners<'a> { /// The radius each of `corners` is drawn with ([`fitted_radii`]). drawn: Vec, kinds: Vec, + /// Stored radii are fractions of each corner's maximum, not document lengths. + relative: bool, /// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from. sources: Vec>, } @@ -34,16 +91,21 @@ impl<'a> LiveCorners<'a> { /// The Live Corners of `path` and its live shape; `None` for shapes without corners (ellipses, /// lines). pub fn new(path: &'a PathData, live: Option<&'a LiveShape>) -> Option { - let (base, xf, radii, kinds) = match live { - None => (Cow::Borrowed(path), Affine::IDENTITY, vec![], vec![]), + let (base, xf, stored, kinds, relative) = match live { + None => (Cow::Borrowed(path), Affine::IDENTITY, vec![], vec![], false), Some(l @ LiveShape::Rectangle { .. }) => { - let LiveShape::Rectangle { w, h, radii, kinds, xf } = l.folded() else { return None }; - (Cow::Owned(shapes::rectangle(Rect::new(0.0, 0.0, w, h))), xf, radii.to_vec(), kinds.to_vec()) + let LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } = l.folded() else { return None }; + (Cow::Owned(shapes::rectangle(Rect::new(0.0, 0.0, w, h))), xf, radii.to_vec(), kinds.to_vec(), radii_relative) + } + Some(l @ LiveShape::Polygon { radii, kinds, radii_relative, .. }) => { + (Cow::Owned(l.polygon_outline()), Affine::IDENTITY, radii.clone(), kinds.clone(), *radii_relative) + } + Some(LiveShape::Path { base, radii, kinds, radii_relative }) => { + (Cow::Borrowed(base), Affine::IDENTITY, radii.clone(), kinds.clone(), *radii_relative) } - Some(l @ LiveShape::Polygon { radii, kinds, .. }) => (Cow::Owned(l.polygon_outline()), Affine::IDENTITY, radii.clone(), kinds.clone()), - Some(LiveShape::Path { base, radii, kinds }) => (Cow::Borrowed(base), Affine::IDENTITY, radii.clone(), kinds.clone()), Some(LiveShape::Ellipse { .. } | LiveShape::Line { .. }) => return None, }; + let radii = resolve_stored_radii(&base, &stored, relative); let sources = if radii.iter().any(|r| *r > 0.0) { cut_corners(&base, &radii, &kinds).1 } else { @@ -58,7 +120,7 @@ impl<'a> LiveCorners<'a> { }; let corners = path_corners(&base); let drawn = fitted_radii(&corners, &radii); - Some(Self { corners, base, xf, radii, drawn, kinds, sources }) + Some(Self { corners, base, xf, radii: stored, drawn, kinds, relative, sources }) } /// The Live Corners of a path object. @@ -72,7 +134,13 @@ impl<'a> LiveCorners<'a> { self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.corners.get(i)) } - /// The radius corner `index` is set to, in document units (drawn no larger than fits). + /// Whether corner radii are stored as fractions of each corner's maximum. + pub fn radii_relative(&self) -> bool { + self.relative + } + + /// The radius corner `index` is stored at: document lengths, or a fraction in 0..1 when + /// [`LiveCorners::radii_relative`]. pub fn radius(&self, index: usize) -> f64 { self.radii.get(index).copied().unwrap_or(0.0) } @@ -126,7 +194,7 @@ impl<'a> LiveCorners<'a> { let first = it.next()?; it.all(|x| same(x, first)).then_some(first) } - let radius = shared(corners.iter().map(|k| self.radius(*k)), |a, b| (a - b).abs() < 1e-9); + let radius = shared(corners.iter().map(|k| self.drawn_radius(*k)), |a, b| (a - b).abs() < 1e-9); (radius, shared(corners.iter().map(|k| self.kind(*k)), |a, b| a == b)) } } @@ -137,29 +205,31 @@ impl<'a> LiveCorners<'a> { /// plain path again once no corner is cut. Ellipses and lines have no corners: they stay as they /// are. pub fn set_corners(path: &mut PathData, live: &mut Option>, corners: &BTreeSet, radius: Option, kind: Option) { - fn edit(radii: &mut [f64], kinds: &mut [CornerKind], corners: &BTreeSet, radius: Option, kind: Option) { + fn edit( + base: &PathData, + relative: bool, + radii: &mut [f64], + kinds: &mut [CornerKind], + corners: &BTreeSet, + radius: Option, + kind: Option, + ) { + let corners_on_base = path_corners(base); for k in corners { if let (Some(r), Some(slot)) = (radius, radii.get_mut(*k)) { - *slot = r.max(0.0); + *slot = if relative { + let max = corners_on_base.iter().find(|c| c.index == *k).map(|c| c.max_radius()).unwrap_or(0.0); + if max > 1e-12 { (r / max).clamp(0.0, 1.0) } else { 0.0 } + } else { + r.max(0.0) + }; } if let (Some(kind), Some(slot)) = (kind, kinds.get_mut(*k)) { *slot = kind; } } } - /// One radius and kind per corner of an outline of `n` anchors while editing; none saved - /// when all are sharp or round. - fn edit_vec( - n: usize, - radii: &mut Vec, - kinds: &mut Vec, - corners: &BTreeSet, - radius: Option, - kind: Option, - ) { - radii.resize(n, 0.0); - kinds.resize(n, CornerKind::Round); - edit(radii, kinds, corners, radius, kind); + fn trim_vec_radii(radii: &mut Vec, kinds: &mut Vec) { if radii.iter().all(|r| *r <= 0.0) { radii.clear(); } @@ -167,18 +237,35 @@ pub fn set_corners(path: &mut PathData, live: &mut Option>, corne kinds.clear(); } } - let l = live.get_or_insert_with(|| Box::new(LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![] })); + let l = live.get_or_insert_with(|| Box::new(LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![], radii_relative: false })); // Radii are document lengths and corners circular, also on a rectangle from a file that kept // an uneven scale in its transform (#442). l.fold_scale(); + let relative = l.radii_relative(); match &mut **l { - LiveShape::Rectangle { radii, kinds, .. } => edit(radii, kinds, corners, radius, kind), - LiveShape::Polygon { sides, radii, kinds, .. } => edit_vec(*sides as usize, radii, kinds, corners, radius, kind), - LiveShape::Path { base, radii, kinds } => edit_vec(base.anchor_count(), radii, kinds, corners, radius, kind), + LiveShape::Rectangle { w, h, radii, kinds, .. } => { + let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h)); + edit(&base, relative, radii, kinds, corners, radius, kind); + } + LiveShape::Polygon { radius: poly_r, sides, xf, radii, kinds, .. } => { + let outline = shapes::polygon(Point::ZERO, *poly_r, *sides, 0.0).transformed(*xf); + let n = *sides as usize; + radii.resize(n, 0.0); + kinds.resize(n, CornerKind::Round); + edit(&outline, relative, radii, kinds, corners, radius, kind); + trim_vec_radii(radii, kinds); + } + LiveShape::Path { base, radii, kinds, .. } => { + let n = base.anchor_count(); + radii.resize(n, 0.0); + kinds.resize(n, CornerKind::Round); + edit(base, relative, radii, kinds, corners, radius, kind); + trim_vec_radii(radii, kinds); + } LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return, } match live.as_deref_mut() { - Some(LiveShape::Path { base, radii, kinds }) if radii.is_empty() && kinds.is_empty() => { + Some(LiveShape::Path { base, radii, kinds, .. }) if radii.is_empty() && kinds.is_empty() => { *path = std::mem::take(base); *live = None; } @@ -202,7 +289,7 @@ mod tests { #[test] fn rectangle_corners_map_to_their_anchors() { - let l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY }; + let l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), radii_relative: false, xf: Affine::IDENTITY }; let path = l.to_path(); let c = LiveCorners::new(&path, Some(&l)).unwrap(); // Anchors: top-left, the top-right corner's two, bottom-right, bottom-left. @@ -229,7 +316,7 @@ mod tests { let star = shapes::star(Point::new(100.0, 100.0), 50.0, 25.0, 5, 0.0); let (mut path, mut live) = (star.clone(), None); set_corners(&mut path, &mut live, &set(&[0, 2]), Some(5.0), None); - let Some(LiveShape::Path { base, radii, kinds }) = live.as_deref() else { panic!("live corners: {live:?}") }; + let Some(LiveShape::Path { base, radii, kinds, .. }) = live.as_deref() else { panic!("live corners: {live:?}") }; assert_eq!((base, radii.len(), kinds.len()), (&star, 10, 0)); assert_eq!(path.anchor_count(), 12, "two corners cut"); let c = LiveCorners::new(&path, live.as_deref()).unwrap(); @@ -245,7 +332,7 @@ mod tests { /// A polygon's corners are cut in the document and survive a change of sides when shared. #[test] fn polygon_corners_follow_their_sides() { - let mut l = LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![] }; + let mut l = LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![], radii_relative: false }; let (mut path, mut live) = (l.to_path(), Some(Box::new(l.clone()))); set_corners(&mut path, &mut live, &set(&[0, 1, 2, 3, 4, 5]), Some(8.0), None); assert_eq!(path.anchor_count(), 12); @@ -265,6 +352,32 @@ mod tests { assert!(!p.transform(Affine::translate((5.0, 0.0))), "a move needs no new cuts"); } + #[test] + fn relative_radii_scale_with_rectangle_size() { + let l = LiveShape::Rectangle { w: 100.0, h: 100.0, radii: [0.25, 0.25, 0.25, 0.25], kinds: Default::default(), radii_relative: true, xf: Affine::IDENTITY }; + let path = l.to_path(); + let c = LiveCorners::new(&path, Some(&l)).unwrap(); + let r0 = c.drawn_radius(0); + let l2 = LiveShape::Rectangle { w: 200.0, h: 200.0, radii: [0.25, 0.25, 0.25, 0.25], kinds: Default::default(), radii_relative: true, xf: Affine::IDENTITY }; + let path2 = l2.to_path(); + let c2 = LiveCorners::new(&path2, Some(&l2)).unwrap(); + assert!((c2.drawn_radius(0) / r0 - 2.0).abs() < 0.05, "{r0} → {}", c2.drawn_radius(0)); + } + + #[test] + fn convert_between_relative_and_absolute_radii() { + let mut l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [10.0, 0.0, 0.0, 0.0], kinds: Default::default(), radii_relative: false, xf: Affine::IDENTITY }; + let before = LiveCorners::new(&l.to_path(), Some(&l)).unwrap().drawn_radius(0); + convert_radii_relative(&mut l, true); + assert!(l.radii_relative()); + let mid = LiveCorners::new(&l.to_path(), Some(&l)).unwrap().drawn_radius(0); + assert!((mid - before).abs() < 0.5); + convert_radii_relative(&mut l, false); + assert!(!l.radii_relative()); + let after = LiveCorners::new(&l.to_path(), Some(&l)).unwrap().drawn_radius(0); + assert!((after - before).abs() < 0.5); + } + #[test] fn open_paths_and_curves_keep_their_ends_and_smooth_anchors() { let pen = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 80.0)], false)); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index c69d91793..2a649ec85 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -88,6 +88,9 @@ pub enum LiveShape { /// Corner kinds, in the same order (Live Corners' corner types). #[serde(default, skip_serializing_if = "all_round")] kinds: [CornerKind; 4], + /// When true, `radii` are fractions in 0..1 of each corner's largest possible radius. + #[serde(default, skip_serializing_if = "is_false")] + radii_relative: bool, /// Maps the untransformed shape (origin at its top-left) into the document. A rotation /// or reflection plus a move, unless a shear (or a file saved before transforms folded /// their scale into `w`/`h`) put more in it. @@ -112,6 +115,8 @@ pub enum LiveShape { /// Corner kinds, in the same order (none: round). #[serde(default, skip_serializing_if = "all_round")] kinds: Vec, + #[serde(default, skip_serializing_if = "is_false")] + radii_relative: bool, }, Line { a: Point, @@ -129,10 +134,22 @@ pub enum LiveShape { /// Corner kinds, in the same order (missing: round). #[serde(default, skip_serializing_if = "all_round")] kinds: Vec, + #[serde(default, skip_serializing_if = "is_false")] + radii_relative: bool, }, } +fn is_false(b: &bool) -> bool { + !*b +} + impl LiveShape { + pub fn radii_relative(&self) -> bool { + match self { + LiveShape::Rectangle { radii_relative, .. } | LiveShape::Polygon { radii_relative, .. } | LiveShape::Path { radii_relative, .. } => *radii_relative, + LiveShape::Ellipse { .. } | LiveShape::Line { .. } => false, + } + } pub fn label(&self) -> &'static str { match self { LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) => "Rounded Rectangle", @@ -146,13 +163,23 @@ impl LiveShape { /// Regenerate the path. pub fn to_path(&self) -> PathData { match self { - LiveShape::Rectangle { w, h, radii, kinds, xf } => { - shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), *radii, *kinds).transformed(*xf) + LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } => { + let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h)); + let resolved = crate::corners::resolve_stored_radii(&base, radii, *radii_relative); + let radii = std::array::from_fn(|i| resolved.get(i).copied().unwrap_or(0.0)); + shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), radii, *kinds).transformed(*xf) } LiveShape::Ellipse { w, h, pie, xf } => shapes::ellipse_pie(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1).transformed(*xf), - LiveShape::Polygon { radii, kinds, .. } => cut_corners(&self.polygon_outline(), radii, kinds).0, + LiveShape::Polygon { radii, kinds, radii_relative, .. } => { + let outline = self.polygon_outline(); + let radii = crate::corners::resolve_stored_radii(&outline, radii, *radii_relative); + cut_corners(&outline, &radii, kinds).0 + } LiveShape::Line { a, b } => shapes::line(*a, *b), - LiveShape::Path { base, radii, kinds } => cut_corners(base, radii, kinds).0, + LiveShape::Path { base, radii, kinds, radii_relative } => { + let radii = crate::corners::resolve_stored_radii(base, radii, *radii_relative); + cut_corners(base, &radii, kinds).0 + } } } /// A polygon's outline with its corners uncut, in the document (empty for other shapes). @@ -253,13 +280,13 @@ impl LiveShape { *xf = a * *xf; false } - LiveShape::Polygon { xf, radii, .. } => { + LiveShape::Polygon { xf, radii, radii_relative, .. } => { *xf = a * *xf; - scale_radii(radii, a) + scale_radii(radii, a, *radii_relative) } - LiveShape::Path { base, radii, .. } => { + LiveShape::Path { base, radii, radii_relative, .. } => { base.transform(a); - scale_radii(radii, a) + scale_radii(radii, a, *radii_relative) } LiveShape::Line { a: p, b } => { *p = a * *p; @@ -274,7 +301,7 @@ impl LiveShape { /// isn't finite, and for other shapes. Files saved before transforms did this (#291) carry an /// uneven scale in `xf`: their corners are elliptical until this runs. pub fn fold_scale(&mut self) -> bool { - let LiveShape::Rectangle { w, h, radii, xf, .. } = self else { return false }; + let LiveShape::Rectangle { w, h, radii, radii_relative, xf, .. } = self else { return false }; let [m0, m1, m2, m3, m4, m5] = xf.as_coeffs(); let (sx, sy) = (m0.hypot(m1), m2.hypot(m3)); let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy; @@ -284,7 +311,9 @@ impl LiveShape { return false; } let k = (sx * sy).sqrt(); - radii.iter_mut().for_each(|r| *r *= k); + if !*radii_relative { + radii.iter_mut().for_each(|r| *r *= k); + } *w *= sx; *h *= sy; *xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]); @@ -300,12 +329,13 @@ impl LiveShape { /// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners /// keep their size (Scale Corners off). False when nothing changed. pub fn keep_corners(&mut self, k: f64) -> bool { + let relative = self.radii_relative(); let radii: &mut [f64] = match self { LiveShape::Rectangle { radii, .. } => radii, LiveShape::Polygon { radii, .. } | LiveShape::Path { radii, .. } => radii, LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return false, }; - if !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 { + if relative || !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 { return false; } radii.iter_mut().for_each(|r| *r /= k); @@ -343,8 +373,8 @@ fn moves_only(a: Affine) -> bool { /// Scale corner radii in document units by the mean scale of `a`, applied to their path. True /// when the path must be cut again: some corner is cut and `a` does more than move it (an /// uneven scale would otherwise make the cuts elliptical). -fn scale_radii(radii: &mut [f64], a: Affine) -> bool { - if moves_only(a) || !radii.iter().any(|r| *r > 0.0) { +fn scale_radii(radii: &mut [f64], a: Affine, relative: bool) -> bool { + if relative || moves_only(a) || !radii.iter().any(|r| *r > 0.0) { return false; } let k = a.determinant().abs().sqrt(); @@ -2175,14 +2205,14 @@ mod tests { #[test] fn rect_live_shape_roundtrip() { - let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate((5.0, 5.0)) }; + let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], kinds: Default::default(), radii_relative: false, xf: Affine::translate((5.0, 5.0)) }; assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0))); assert_eq!(l.label(), "Rectangle"); } #[test] fn corner_kinds_save_only_when_some_corner_isnt_round() { - let mut l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY }; + let mut l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), radii_relative: false, xf: Affine::IDENTITY }; // Older files read as round. let json = serde_json::to_value(&l).unwrap(); assert!(json.get("kinds").is_none(), "{json}"); @@ -2196,9 +2226,9 @@ mod tests { // Polygons saved before Live Corners cut theirs read as sharp; a live path round-trips. let p: LiveShape = serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap(); - assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![] }); + assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![], radii_relative: false }); let star = shapes::star(Point::new(0.0, 0.0), 20.0, 10.0, 5, 0.0); - let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![] }; + let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![], radii_relative: false }; let json = serde_json::to_value(&path).unwrap(); assert_eq!((json["shape"].as_str(), json.get("kinds")), (Some("path"), None)); assert_eq!(serde_json::from_value::(json).unwrap(), path); @@ -2228,7 +2258,7 @@ mod tests { let close = |a: f64, b: f64| (a - b).abs() < 1e-9; // A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page. let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0); - let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![] }; + let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![], radii_relative: false }; assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal()); p.set_polygon_angle(45.0); assert!(close(p.polygon_angle().unwrap(), 45.0)); @@ -2253,7 +2283,7 @@ mod tests { /// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path. fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node { - let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf }; + let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf }; let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art()); if let NodeKind::Path { live: slot, .. } = &mut n.kind { *slot = Some(Box::new(live)); diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 459fde9f9..da30cc642 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -910,13 +910,14 @@ fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveSha h: height, radii: [radius; 4], kinds: Default::default(), + radii_relative: false, xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)), }, tr::TracedShape::Ellipse { center, rx, ry, rotation } => { LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) } } tr::TracedShape::Polygon { center, radius, sides, rotation } => { - LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![] } + LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![], radii_relative: false } } }; // (Whether the move changed the shape's own numbers does not matter: its path is made from them next.) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 5aad77387..36ca0c462 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -214,6 +214,7 @@ fn rectangle(s: &mut Session, p: &Value) -> Result { h: r.height(), radii: [radius; 4], kinds: Default::default(), + radii_relative: false, xf: rect_transform(r, p, "shape.rectangle")?, }; let label = if radius > 0.0 { "Rounded Rectangle" } else { "Rectangle" }; @@ -232,7 +233,7 @@ fn polygon(s: &mut Session, p: &Value) -> Result { let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32; let rot = f64_or(p, "rotation", 0.0); let xf = Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians()); - let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![] }; + let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![], radii_relative: false }; add_art(s, "Polygon", path_kind(live.to_path(), Some(live)), None) } diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 38de3f801..acd9a9b28 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -899,7 +899,7 @@ pub(crate) fn detect_shape(path: &PathData) -> Option { } let rot = Affine::rotate(e0.y.atan2(e0.x)); let flip = if e0.cross(e1) < 0.0 { Affine::scale_non_uniform(1.0, -1.0) } else { Affine::IDENTITY }; - return Some(LiveShape::Rectangle { w, h, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate(a[0].p.to_vec2()) * rot * flip }); + return Some(LiveShape::Rectangle { w, h, radii: [0.0; 4], kinds: Default::default(), radii_relative: false, xf: Affine::translate(a[0].p.to_vec2()) * rot * flip }); } let b = path.bounds()?; let c = b.center(); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index d8f90b25d..085991d59 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::corners::set_corners; +use vectorcraft_doc::corners::{convert_radii_relative, set_corners}; use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, Node, NodeId, NodeKind, OrientedBox, Scaling}; use vectorcraft_geom::shapes::CornerKind; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2}; @@ -289,7 +289,7 @@ pub fn specs() -> Vec { "Live Shape Properties", [], None, - "{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)", + "{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Illustrator Relative rounding: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)", has_selection, set_live_shape ), @@ -2334,6 +2334,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { }) .transpose()?; let radius = p.get("radius").and_then(Value::as_f64); + let relative_radii = p.get("relativeRadii").and_then(Value::as_bool); let sides = p.get("sides").and_then(Value::as_u64); // A number that must be `what` (None when absent). let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result> { @@ -2378,6 +2379,9 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false, _ => {} } + if relative_radii.is_some_and(|on| live.as_deref().map(|l| l.radii_relative()) != Some(on)) { + return false; + } if radius.is_none() && kind.is_none() { return true; } @@ -2386,9 +2390,11 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { // Setting corners folds a scale left in a rectangle's transform into its size, which // changes it. live.as_deref().is_none_or(|l| l.folded() == *l) - && picked - .iter() - .all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd)) + && picked.iter().all(|k| { + *k < c.base.anchor_count() + && radius.is_none_or(|r| (c.drawn_radius(*k) - r.max(0.0)).abs() < 1e-9) + && kind.is_none_or(|kd| c.kind(*k) == kd) + }) }; if ids.iter().all(has_values) { return ok(); @@ -2422,6 +2428,12 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { } *path = l.to_path(); } + if let Some(on) = relative_radii { + if let Some(l) = live.as_deref_mut() { + convert_radii_relative(l, on); + *path = l.to_path(); + } + } if radius.is_none() && kind.is_none() { continue; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 41d757752..7ac01ea1a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1301,7 +1301,7 @@ fn live_shape_properties_already_set_record_no_undo_step() { let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(r).unwrap().kind else { panic!("not a live rectangle") }; let xf = Affine::scale_non_uniform(2.0, 1.0); - **live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; + **live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), radii_relative: false, xf }; *path = live.to_path(); } let shown = vectorcraft_doc::LiveCorners::of(s.doc().unwrap().doc.node(r).unwrap()).unwrap().radius(0); diff --git a/crates/engine/src/tests_livecorners.rs b/crates/engine/src/tests_livecorners.rs index 14c7a2631..8a7f833e8 100644 --- a/crates/engine/src/tests_livecorners.rs +++ b/crates/engine/src/tests_livecorners.rs @@ -158,7 +158,7 @@ fn corners_of_a_non_square_rectangle_are_alike_circles() { let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(id).unwrap().kind else { panic!("not live") }; let xf = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(2.0, 1.0); - **live = LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; + **live = LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), radii_relative: false, xf }; *path = live.to_path(); } assert_eq!(spans(&s, id)[0], (20.0, 10.0)); @@ -419,3 +419,26 @@ fn a_polygon_takes_its_angle_radius_side_length_and_equal_sides() { assert!(s.execute("object.setLiveShape", &bad).is_err(), "{bad}"); } } + +#[test] +fn relative_corner_rounding_keeps_fraction_on_resize() { + let (mut s, id) = session(); + run(&mut s, json!({"relativeRadii": true})); + let n = s.doc().unwrap().doc.node(id).unwrap(); + let c = LiveCorners::of(n).unwrap(); + let max = c.corner(0).unwrap().max_radius(); + let want = 0.25; + run(&mut s, json!({"radius": want * max})); + let n = s.doc().unwrap().doc.node(id).unwrap(); + let LiveShape::Rectangle { radii, radii_relative, .. } = n.live_shape().unwrap() else { panic!() }; + assert!(radii_relative); + assert!((radii[0] - want).abs() < 0.02, "stored fraction: {}", radii[0]); + let drawn = LiveCorners::of(n).unwrap().drawn_radius(0); + s.execute("object.scale", &json!({"sx": 200, "sy": 200, "corners": true})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap(); + let LiveShape::Rectangle { radii, radii_relative, .. } = n.live_shape().unwrap() else { panic!() }; + assert!(radii_relative); + assert!((radii[0] - want).abs() < 0.02, "fraction unchanged after scale: {}", radii[0]); + let c = LiveCorners::of(n).unwrap(); + assert!((c.drawn_radius(0) / drawn - 2.0).abs() < 0.05, "drawn radius doubled with the shape"); +} diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 75853337a..52f3ab563 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -664,12 +664,15 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option { // Sizes and radii in document units. match live.map(LiveShape::folded).as_ref() { // CSS rounds corners only: an inverted round or chamfered corner has no border radius. - Some(LiveShape::Rectangle { w, h, radii, kinds, xf }) + Some(LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf }) if upright(xf) && radii.iter().zip(kinds).all(|(r, k)| *r <= 0.0 || *k == CornerKind::Round) => { // 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); + let base = shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, *w, *h)); + let resolved = vectorcraft_doc::corners::resolve_stored_radii(&base, radii, *radii_relative); + let stored = std::array::from_fn(|i| resolved.get(i).copied().unwrap_or(0.0)); + let radii = shapes::fitted_corner_radii(*w, *h, stored); 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 a3c586096..70962b559 100644 --- a/crates/svg/tests/css_rules.rs +++ b/crates/svg/tests/css_rules.rs @@ -30,7 +30,7 @@ fn rect(d: &mut Document, (x, y, w, h): (f64, f64, f64, f64), radius: f64) -> No Appearance::basic(fill, Paint::solid(Color::rgb8(0, 0, 255)), 2.0), ); if let NodeKind::Path { live, .. } = &mut n.kind { - *live = Some(Box::new(LiveShape::Rectangle { w, h, radii: [radius; 4], kinds: Default::default(), xf: Affine::translate((x, y)) })); + *live = Some(Box::new(LiveShape::Rectangle { w, h, radii: [radius; 4], kinds: Default::default(), radii_relative: false, xf: Affine::translate((x, y)) })); } n } diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index 0d5f38eba..0183f69dd 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -402,7 +402,7 @@ mod tests { /// A live `w` × `h` rectangle with corner radius `r`. fn live_shape(w: f64, h: f64, r: f64, xf: Affine) -> (Document, NodeId) { - let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf }; + let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf }; doc_with(live.to_path(), Some(live)) } @@ -485,7 +485,7 @@ mod tests { #[test] fn widgets_of_several_selected_paths_drag_together() { let (mut d, a) = live_rect(0.0, Affine::translate((100.0, 100.0))); - let live = LiveShape::Rectangle { w: 100.0, h: 100.0, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate((300.0, 100.0)) }; + let live = LiveShape::Rectangle { w: 100.0, h: 100.0, radii: [0.0; 4], kinds: Default::default(), radii_relative: false, xf: Affine::translate((300.0, 100.0)) }; let b = d.alloc_id(); let mut n = Node::path(b, live.to_path(), Appearance::default_art()); if let NodeKind::Path { live: slot, .. } = &mut n.kind { @@ -641,6 +641,7 @@ mod tests { h: 100.0, radii: [0.0, top_right, 0.0, 0.0], kinds: Default::default(), + radii_relative: false, xf: Affine::translate((100.0, 100.0)), }; let (d, id) = doc_with(live.to_path(), Some(live)); @@ -791,7 +792,7 @@ mod tests { /// cuts outline in red. #[test] fn a_live_polygon_rounds_with_either_tool() { - let live = LiveShape::Polygon { radius: 80.0, sides: 6, xf: Affine::translate((200.0, 200.0)), radii: vec![], kinds: vec![] }; + let live = LiveShape::Polygon { radius: 80.0, sides: 6, xf: Affine::translate((200.0, 200.0)), radii: vec![], kinds: vec![], radii_relative: false }; let (d, id) = doc_with(live.to_path(), Some(live)); let s = selected(id); let p = paint(); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 7daff56a0..a8e33caa4 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1031,6 +1031,7 @@ mod tests { h: 100.0, radii: [0.0; 4], kinds: Default::default(), + radii_relative: false, xf: vectorcraft_geom::Affine::translate((100.0, 100.0)), }; let mut d = vectorcraft_doc::Document::new(500.0, 500.0); diff --git a/crates/ui-egui/src/dialogs/corners.rs b/crates/ui-egui/src/dialogs/corners.rs index 8f1342eb3..2d72072e6 100644 --- a/crates/ui-egui/src/dialogs/corners.rs +++ b/crates/ui-egui/src/dialogs/corners.rs @@ -49,12 +49,20 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { return Err("give corners of the path: anchors where its sides meet at an angle".into()); } let (radius, kind) = live.style(&corners); - let mut fields = json!({"id": id.0, "corners": corners}); + let relative = st.doc.node(id).and_then(|n| n.live_shape()).map(|l| l.radii_relative()).unwrap_or(false); + let mut fields = json!({"id": id.0, "corners": corners, "relativeRadii": relative}); if let Some(k) = kind { fields["kind"] = json!(k); } if let Some(r) = radius { - fields["radius"] = json!(r); + if relative { + let pct = corners.iter().find_map(|k| live.corner(*k).map(|_| live.radius(*k) * 100.0)); + if let Some(pct) = pct { + fields["radius"] = json!(pct); + } + } else { + fields["radius"] = json!(r); + } } app.ui.dialog = Some(Dialog::new(KIND, fields)); Ok(Value::Null) @@ -73,8 +81,24 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } }); ui.end_row(); + ui.horizontal(|ui| { + for (label, on) in [(tl!("Relative"), true), (tl!("Absolute"), false)] { + let cur = d.fields.get("relativeRadii").and_then(|v| v.as_bool()).unwrap_or(false); + if widgets::radio(ui, label, cur == on, true) { + d.fields.insert("relativeRadii".into(), json!(on)); + } + } + }); + ui.end_row(); label(ui, tl!("Radius:")); - form::length(ui, d, "radius", unit, 120.0); + let relative = d.fields.get("relativeRadii").and_then(|v| v.as_bool()).unwrap_or(false); + if relative { + if let Some(n) = widgets::range_field(ui, ("corners", "radius"), d.f64("radius", 0.0), 0.0..=100.0, "%", 1, 120.0) { + d.fields.insert("radius".into(), json!(n)); + } + } else { + form::length(ui, d, "radius", unit, 120.0); + } ui.end_row(); }); false @@ -94,8 +118,24 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if let Some(k) = d.fields.get("kind") { p["kind"] = k.clone(); } + if let Some(v) = d.fields.get("relativeRadii") { + p["relativeRadii"] = v.clone(); + } if d.fields.contains_key("radius") { - p["radius"] = json!(d.f64("radius", 0.0)); + let relative = d.fields.get("relativeRadii").and_then(|v| v.as_bool()).unwrap_or(false); + let mut r = d.f64("radius", 0.0); + if relative { + let id = NodeId(d.fields.get("id").and_then(|v| v.as_u64()).unwrap_or(0)); + let st = app.session.active().ok_or("no document")?; + if let Some(lc) = st.doc.node(id).and_then(LiveCorners::of) { + let corners: BTreeSet = serde_json::from_value(d.fields.get("corners").cloned().unwrap_or(json!([]))).unwrap_or_default(); + if let Some(k) = corners.iter().next() { + let max = lc.corner(*k).map(|c| c.max_radius()).unwrap_or(0.0); + r = (r / 100.0).clamp(0.0, 1.0) * max; + } + } + } + p["radius"] = json!(r); } run_and_close(app, "object.setLiveShape", p) }