mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
426 lines
19 KiB
Rust
426 lines
19 KiB
Rust
//! Live Corners on any path: the corners of its uncut outline (a live rectangle's or polygon's, a
|
|
//! live path's, or a plain path's own), each one's radius and kind, and the anchors of the path as
|
|
//! drawn that belong to each. [`set_corners`] edits them.
|
|
|
|
use std::borrow::Cow;
|
|
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, 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> {
|
|
/// The path with its corners uncut, in the corners' own space: a live rectangle's (its size
|
|
/// and radii in document units, see [`LiveShape::folded`]), else the document's.
|
|
pub base: Cow<'a, PathData>,
|
|
/// Maps `base` into the document.
|
|
pub xf: Affine,
|
|
/// The corners that can be cut, in anchor order ([`path_corners`] of `base`).
|
|
pub corners: Vec<Corner>,
|
|
radii: Vec<f64>,
|
|
/// The radius each of `corners` is drawn with ([`fitted_radii`]).
|
|
drawn: Vec<f64>,
|
|
kinds: Vec<CornerKind>,
|
|
/// 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>>,
|
|
}
|
|
|
|
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, 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, 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(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 {
|
|
let mut first = 0;
|
|
base.subpaths
|
|
.iter()
|
|
.map(|sp| {
|
|
first += sp.anchors.len();
|
|
(first - sp.anchors.len()..first).collect()
|
|
})
|
|
.collect()
|
|
};
|
|
let corners = path_corners(&base);
|
|
let drawn = fitted_radii(&corners, &radii);
|
|
Some(Self { corners, base, xf, radii: stored, drawn, kinds, relative, sources })
|
|
}
|
|
|
|
/// The Live Corners of a path object.
|
|
pub fn of(node: &'a Node) -> Option<Self> {
|
|
let NodeKind::Path { path, live, .. } = &node.kind else { return None };
|
|
Self::new(path, live.as_deref())
|
|
}
|
|
|
|
/// The corner at anchor `index` of `base`.
|
|
pub fn corner(&self, index: usize) -> Option<&Corner> {
|
|
self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.corners.get(i))
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// The radius corner `index` is drawn with, in document units: its own, no larger than its
|
|
/// neighbours leave room for (0 for an anchor that is no corner).
|
|
pub fn drawn_radius(&self, index: usize) -> f64 {
|
|
self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.drawn.get(i)).copied().unwrap_or(0.0)
|
|
}
|
|
/// The kind corner `index` is cut with.
|
|
pub fn kind(&self, index: usize) -> CornerKind {
|
|
self.kinds.get(index).copied().unwrap_or_default()
|
|
}
|
|
|
|
/// Every corner.
|
|
pub fn all(&self) -> BTreeSet<usize> {
|
|
self.corners.iter().map(|c| c.index).collect()
|
|
}
|
|
|
|
/// The corners holding one of `anchors` (of the path as drawn).
|
|
pub fn corners_of(&self, anchors: &BTreeSet<AnchorRef>) -> BTreeSet<usize> {
|
|
anchors.iter().filter_map(|(si, ai)| self.sources.get(*si)?.get(*ai).copied()).filter(|k| self.corner(*k).is_some()).collect()
|
|
}
|
|
|
|
/// The corners Live Corners edit for a selection: those holding a selected anchor when the
|
|
/// path is partly selected (Direct Selection), else every corner.
|
|
pub fn picked(&self, partial: Option<&BTreeSet<AnchorRef>>) -> BTreeSet<usize> {
|
|
let picked = partial.map(|a| self.corners_of(a)).unwrap_or_default();
|
|
if picked.is_empty() { self.all() } else { picked }
|
|
}
|
|
|
|
/// The anchors of the path as drawn that belong to `corners` (to keep them selected as the
|
|
/// path changes).
|
|
pub fn anchors_of(&self, corners: &BTreeSet<usize>) -> BTreeSet<AnchorRef> {
|
|
let mut out = BTreeSet::new();
|
|
for (si, from) in self.sources.iter().enumerate() {
|
|
out.extend(from.iter().enumerate().filter(|(_, k)| corners.contains(k)).map(|(ai, _)| (si, ai)));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from.
|
|
pub fn sources(&self) -> &[Vec<usize>] {
|
|
&self.sources
|
|
}
|
|
|
|
/// The radius and kind `corners` share (each `None` when they differ, or for no corners).
|
|
/// Radius is what was set ([`LiveCorners::radius`]: document lengths, or a fraction in 0..1
|
|
/// when [`LiveCorners::radii_relative`]), not the fitted draw size ([`LiveCorners::drawn_radius`]).
|
|
pub fn style(&self, corners: &BTreeSet<usize>) -> (Option<f64>, Option<CornerKind>) {
|
|
fn shared<T: Copy>(mut it: impl Iterator<Item = T>, same: impl Fn(T, T) -> bool) -> Option<T> {
|
|
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);
|
|
(radius, shared(corners.iter().map(|k| self.kind(*k)), |a, b| a == b))
|
|
}
|
|
}
|
|
|
|
/// Set `radius` (document units; negative: 0) and/or `kind` on the `corners` (anchor indices of
|
|
/// the uncut outline, see [`LiveCorners`]) of a path and its live shape, then cut the path again.
|
|
/// A path that isn't a live shape keeps its uncut outline as a [`LiveShape::Path`], which is 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(
|
|
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 = 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;
|
|
}
|
|
}
|
|
}
|
|
fn trim_vec_radii(radii: &mut Vec<f64>, kinds: &mut Vec<CornerKind>) {
|
|
if radii.iter().all(|r| *r <= 0.0) {
|
|
radii.clear();
|
|
}
|
|
if kinds.iter().all(|k| *k == CornerKind::Round) {
|
|
kinds.clear();
|
|
}
|
|
}
|
|
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 { 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() => {
|
|
*path = std::mem::take(base);
|
|
*live = None;
|
|
}
|
|
Some(l) => *path = l.to_path(),
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use vectorcraft_geom::{Point, SubPath};
|
|
|
|
fn set(v: &[usize]) -> BTreeSet<usize> {
|
|
v.iter().copied().collect()
|
|
}
|
|
|
|
fn anchors(v: &[usize]) -> BTreeSet<AnchorRef> {
|
|
v.iter().map(|ai| (0, *ai)).collect()
|
|
}
|
|
|
|
#[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(),
|
|
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.
|
|
assert_eq!(c.sources(), [vec![0, 1, 1, 2, 3]]);
|
|
assert_eq!(path.anchor_count(), 5);
|
|
assert_eq!(c.corners_of(&anchors(&[2, 4])), set(&[1, 3]));
|
|
assert!(c.corners_of(&anchors(&[9])).is_empty(), "out of range");
|
|
assert_eq!(c.anchors_of(&set(&[1, 3])), anchors(&[1, 2, 4]));
|
|
// Partly selected: the corners of the selected anchors; wholly (or no anchor): all four.
|
|
assert_eq!(c.picked(Some(&anchors(&[0]))), set(&[0]));
|
|
assert_eq!(c.picked(None), set(&[0, 1, 2, 3]));
|
|
assert_eq!(c.picked(Some(&anchors(&[]))), set(&[0, 1, 2, 3]));
|
|
assert_eq!(c.style(&set(&[1])), (Some(10.0), Some(CornerKind::Round)));
|
|
assert_eq!(c.style(&set(&[0, 1])), (None, Some(CornerKind::Round)));
|
|
assert_eq!(c.style(&set(&[0, 2, 3])), (Some(0.0), Some(CornerKind::Round)));
|
|
let e = LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY };
|
|
assert!(LiveCorners::new(&e.to_path(), Some(&e)).is_none());
|
|
}
|
|
|
|
/// #511: a plain path (a star) rounds its corners and keeps them editable; with none cut it
|
|
/// is plain again.
|
|
#[test]
|
|
fn a_plain_path_keeps_its_cut_corners_live() {
|
|
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:?}") };
|
|
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();
|
|
assert_eq!(c.style(&set(&[0, 2])), (Some(5.0), Some(CornerKind::Round)));
|
|
assert_eq!(c.corners_of(&anchors(&[0, 11])), set(&[0]), "the first corner's cut ends the path");
|
|
// Kinds alone keep it live; squaring every corner makes it the plain star again.
|
|
set_corners(&mut path, &mut live, &set(&[0, 2]), Some(0.0), Some(CornerKind::Chamfer));
|
|
assert!(matches!(live.as_deref(), Some(LiveShape::Path { radii, .. }) if radii.is_empty()));
|
|
set_corners(&mut path, &mut live, &set(&[0, 2]), None, Some(CornerKind::Round));
|
|
assert_eq!((path, live), (star, None));
|
|
}
|
|
|
|
/// 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![], 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);
|
|
l = *live.clone().unwrap();
|
|
l.set_sides(8);
|
|
assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == vec![8.0; 8]));
|
|
assert_eq!(l.to_path().anchor_count(), 16);
|
|
// Mixed radii: the new corners are sharp.
|
|
set_corners(&mut path, &mut live, &set(&[1]), Some(3.0), None);
|
|
let mut l = *live.unwrap();
|
|
l.set_sides(7);
|
|
assert!(matches!(&l, LiveShape::Polygon { radii, .. } if *radii == [8.0, 3.0, 8.0, 8.0, 8.0, 8.0, 0.0]));
|
|
// An uneven scale keeps the cuts circular: the radius scales by the mean scale.
|
|
let mut p = l.clone();
|
|
assert!(p.transform(Affine::scale_non_uniform(4.0, 1.0)));
|
|
assert!(matches!(&p, LiveShape::Polygon { radii, .. } if (radii[0] - 16.0).abs() < 1e-9));
|
|
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));
|
|
let c = LiveCorners::new(&pen, None).unwrap();
|
|
assert_eq!(c.all(), set(&[1]));
|
|
assert_eq!(c.picked(Some(&anchors(&[0]))), set(&[1]), "an end is no corner: every corner");
|
|
let (mut path, mut live) = (pen.clone(), None);
|
|
set_corners(&mut path, &mut live, &c.all(), Some(10.0), None);
|
|
assert_eq!(path.anchor_count(), 4);
|
|
assert_eq!(path.subpaths[0].anchors[0].p, Point::new(0.0, 0.0));
|
|
assert_eq!(path.subpaths[0].anchors[3].p, Point::new(100.0, 80.0));
|
|
}
|
|
}
|