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 <cursoragent@cursor.com>
This commit is contained in:
nrq6993-a11y
2026-10-21 23:38:03 +07:00
committed by bflatastic
co-authored by Cursor
parent b13072b77c
commit ab0ddf0eae
13 changed files with 297 additions and 72 deletions
+146 -33
View File
@@ -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<f64> {
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<f64>,
kinds: Vec<CornerKind>,
/// 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<Vec<usize>>,
}
@@ -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<Self> {
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<Box<LiveShape>>, corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
fn edit(radii: &mut [f64], kinds: &mut [CornerKind], corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
fn edit(
base: &PathData,
relative: bool,
radii: &mut [f64],
kinds: &mut [CornerKind],
corners: &BTreeSet<usize>,
radius: Option<f64>,
kind: Option<CornerKind>,
) {
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<f64>,
kinds: &mut Vec<CornerKind>,
corners: &BTreeSet<usize>,
radius: Option<f64>,
kind: Option<CornerKind>,
) {
radii.resize(n, 0.0);
kinds.resize(n, CornerKind::Round);
edit(radii, kinds, corners, radius, kind);
fn trim_vec_radii(radii: &mut Vec<f64>, kinds: &mut Vec<CornerKind>) {
if radii.iter().all(|r| *r <= 0.0) {
radii.clear();
}
@@ -167,18 +237,35 @@ pub fn set_corners(path: &mut PathData, live: &mut Option<Box<LiveShape>>, 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));
+49 -19
View File
@@ -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<CornerKind>,
#[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<CornerKind>,
#[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::<LiveShape>(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));
+2 -1
View File
@@ -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.)
+2 -1
View File
@@ -214,6 +214,7 @@ fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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)
}
+1 -1
View File
@@ -899,7 +899,7 @@ pub(crate) fn detect_shape(path: &PathData) -> Option<LiveShape> {
}
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();
+17 -5
View File
@@ -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<CommandSpec> {
"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<Value> {
})
.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<Option<f64>> {
@@ -2378,6 +2379,9 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
// 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<Value> {
}
*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;
}
+1 -1
View File
@@ -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);
+24 -1
View File
@@ -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");
}
+5 -2
View File
@@ -664,12 +664,15 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option<Corners> {
// 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) {
+1 -1
View File
@@ -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
}
+4 -3
View File
@@ -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();
+1
View File
@@ -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);
+44 -4
View File
@@ -49,12 +49,20 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
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<Value, String> {
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<usize> = 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)
}