mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
committed by
bflatastic
co-authored by
Cursor
parent
b13072b77c
commit
ab0ddf0eae
+146
-33
@@ -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
@@ -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));
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user