mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 01:14:29 -06:00
Set Corners: round only the corners in an angle range (#5545)
Squashed from #5737 onto main.
This commit is contained in:
@@ -20,7 +20,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Set Corners",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [id…] (the selection), anchors?: [index…] (of each path's outline with no corners cut; else its direct-selected anchors, or all of them), items?: [{id, anchors?}…] (instead of ids: each path with its own anchors), radius: pt 0..1000000 (drawn no larger than fits), kind?: \"round\"|\"invertedRound\"|\"chamfer\" (each corner's own), every?: \"all\"|\"odd\"|\"even\" (default all: of those anchors, only the odd- or even-numbered ones, counting each path's anchors from 1)} cut live corners at the anchors that can have one, as one undo step → {ids, corners (how many)}",
|
||||
"{ids?: [id…] (the selection), anchors?: [index…] (of each path's outline with no corners cut; else its direct-selected anchors, or all of them), items?: [{id, anchors?}…] (instead of ids: each path with its own anchors), radius: pt 0..1000000 (drawn no larger than fits), kind?: \"round\"|\"invertedRound\"|\"chamfer\" (each corner's own), every?: \"all\"|\"odd\"|\"even\" (default all: of those anchors, only the odd- or even-numbered ones, counting each path's anchors from 1), minAngle?: degrees 0..180 (default 0), maxAngle?: degrees 0..180 (default 180; of those anchors, only the corners whose sides meet at an angle in this range: small for a sharp spike, near 180 for a shallow bend)} cut live corners at the anchors that can have one, as one undo step → {ids, corners (how many)}",
|
||||
has_doc,
|
||||
corner_set
|
||||
),
|
||||
@@ -84,19 +84,74 @@ impl Every {
|
||||
}
|
||||
}
|
||||
|
||||
/// The error when none of the chosen corners is one it takes.
|
||||
fn none(self) -> &'static str {
|
||||
/// Where the corners it takes are, for an error: "" for all of them.
|
||||
fn at(self) -> &'static str {
|
||||
match self {
|
||||
Self::All => "choose anchors where two sides meet at an angle: only those take a corner",
|
||||
Self::Odd => "none of the chosen corners is at an odd-numbered anchor (the 1st, 3rd, 5th…)",
|
||||
Self::Even => "none of the chosen corners is at an even-numbered anchor (the 2nd, 4th, 6th…)",
|
||||
Self::All => "",
|
||||
Self::Odd => " at an odd-numbered anchor (the 1st, 3rd, 5th…)",
|
||||
Self::Even => " at an even-numbered anchor (the 2nd, 4th, 6th…)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (degrees) past the ends of the angle range a corner's angle may be, so that a typed 90
|
||||
/// takes a rectangle's corners whatever the rounding.
|
||||
const ANGLE_SLACK: f64 = 1e-6;
|
||||
/// The error when no anchor named is a corner at all.
|
||||
const NO_CORNER: &str = "choose anchors where two sides meet at an angle: only those take a corner";
|
||||
|
||||
/// Which of the chosen corners `cornerSet` takes: by their number (`every`) and by the angle their
|
||||
/// sides meet at (`minAngle`..`maxAngle`, degrees).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Pick {
|
||||
every: Every,
|
||||
min_angle: f64,
|
||||
max_angle: f64,
|
||||
}
|
||||
|
||||
impl Pick {
|
||||
/// Every chosen corner.
|
||||
const ALL: Self = Self { every: Every::All, min_angle: 0.0, max_angle: 180.0 };
|
||||
|
||||
/// The `every`, `minAngle` and `maxAngle` of `p`.
|
||||
fn of(p: &Value, cmd: &str) -> Result<Self> {
|
||||
let every = Every::of(p, cmd)?;
|
||||
let angle = |key: &str, default: f64| -> Result<f64> {
|
||||
let a = number_or(p, key, default, cmd)?;
|
||||
if !(a.is_finite() && (0.0..=180.0).contains(&a)) {
|
||||
return Err(bad(cmd, format!("`{key}` must be from 0 to 180 degrees, not {a}")));
|
||||
}
|
||||
Ok(a)
|
||||
};
|
||||
let (min_angle, max_angle) = (angle("minAngle", 0.0)?, angle("maxAngle", 180.0)?);
|
||||
if min_angle > max_angle {
|
||||
return Err(bad(cmd, format!("`minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°)")));
|
||||
}
|
||||
Ok(Self { every, min_angle, max_angle })
|
||||
}
|
||||
|
||||
/// Does it take corner `index` of `lc`?
|
||||
fn takes(&self, lc: &LiveCorners, index: usize) -> bool {
|
||||
self.every.takes(index)
|
||||
&& lc.corner(index).is_some_and(|c| (self.min_angle - ANGLE_SLACK..=self.max_angle + ANGLE_SLACK).contains(&c.angle_deg()))
|
||||
}
|
||||
|
||||
/// The error when it takes none of the chosen corners.
|
||||
fn none(&self) -> String {
|
||||
let at = self.every.at();
|
||||
if self.min_angle > 0.0 || self.max_angle < 180.0 {
|
||||
format!("none of the chosen corners{at} has an angle from {}° to {}°", self.min_angle, self.max_angle)
|
||||
} else if at.is_empty() {
|
||||
NO_CORNER.into()
|
||||
} else {
|
||||
format!("none of the chosen corners is{at}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The corners a request names: each path with the corners (anchor indices of its uncut outline)
|
||||
/// that `every` takes.
|
||||
fn named_corners(s: &Session, p: &Value, cmd: &str, every: Every) -> Result<Vec<(NodeId, BTreeSet<usize>)>> {
|
||||
/// that `pick` takes.
|
||||
fn named_corners(s: &Session, p: &Value, cmd: &str, pick: Pick) -> Result<Vec<(NodeId, BTreeSet<usize>)>> {
|
||||
let shared = anchors_param(p, cmd)?;
|
||||
// Each path with its own `anchors`, or sharing the request's.
|
||||
let mut own: Vec<Option<BTreeSet<usize>>> = vec![];
|
||||
@@ -130,13 +185,13 @@ fn named_corners(s: &Session, p: &Value, cmd: &str, every: Every) -> Result<Vec<
|
||||
None => st.selection.partial(*id).filter(|a| !a.is_empty()).map_or_else(|| lc.all(), |a| lc.corners_of(a)),
|
||||
};
|
||||
chosen |= !corners.is_empty();
|
||||
let corners: BTreeSet<usize> = corners.into_iter().filter(|k| every.takes(*k)).collect();
|
||||
let corners: BTreeSet<usize> = corners.into_iter().filter(|k| pick.takes(&lc, *k)).collect();
|
||||
if !corners.is_empty() {
|
||||
out.push((*id, corners));
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(bad(cmd, if chosen { every.none() } else { Every::All.none() }));
|
||||
return Err(bad(cmd, if chosen { pick.none() } else { NO_CORNER.into() }));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -181,12 +236,12 @@ fn corner_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map_err(|_| bad(C, format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}")))
|
||||
})
|
||||
.transpose()?;
|
||||
let every = Every::of(p, C)?;
|
||||
let corners = named_corners(s, p, C, every)?;
|
||||
let pick = Pick::of(p, C)?;
|
||||
let corners = named_corners(s, p, C, pick)?;
|
||||
edit_corners(s, "Set Corners", corners, radius, kind)
|
||||
}
|
||||
|
||||
fn corner_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let corners = named_corners(s, p, "addon.pathtools.cornerRemove", Every::All)?;
|
||||
let corners = named_corners(s, p, "addon.pathtools.cornerRemove", Pick::ALL)?;
|
||||
edit_corners(s, "Remove Corners", corners, 0.0, Some(CornerKind::Round))
|
||||
}
|
||||
|
||||
@@ -654,6 +654,83 @@ fn corner_set_every_refuses_bad_values_and_says_when_nothing_is_left() {
|
||||
assert!(e.contains("two sides meet"), "{e}");
|
||||
}
|
||||
|
||||
/// A closed house: a 100 × 100 square under a roof, its corners 90°, 90°, 135°, 90° and 135°.
|
||||
fn house(s: &mut Session) -> NodeId {
|
||||
line(s, &[(250.0, 100.0), (350.0, 100.0), (350.0, 200.0), (300.0, 250.0), (250.0, 200.0)], true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_set_angle_range_rounds_only_the_corners_in_it_on_each_path() {
|
||||
let mut s = session();
|
||||
// The star's points meet at about 38°, its inner corners at about 110°.
|
||||
let (star, home) = (star_path(&mut s), house(&mut s));
|
||||
select(&mut s, &[star, home]);
|
||||
let n = steps(&s);
|
||||
// The sharp spikes only: the house has none, the star five, as one step.
|
||||
let r = s.execute("addon.pathtools.cornerSet", &json!({"radius": 3, "maxAngle": 60})).unwrap();
|
||||
assert_eq!((r["ids"].clone(), r["corners"].clone(), steps(&s)), (json!([star.0]), json!(5), n + 1));
|
||||
assert_eq!((cut(&s, star), cut(&s, home)), (vec![0, 2, 4, 6, 8], vec![]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((cut(&s, star), steps(&s)), (vec![], n));
|
||||
// Exactly 90°: the house's square corners.
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"radius": 3, "minAngle": 90, "maxAngle": 90})).unwrap();
|
||||
assert_eq!((cut(&s, star), cut(&s, home)), (vec![], vec![0, 1, 3]));
|
||||
// 100° to 140°: the star's inner corners and the roof's; the square corners stay as they are.
|
||||
let r = s.execute("addon.pathtools.cornerSet", &json!({"radius": 2, "minAngle": 100, "maxAngle": 140})).unwrap();
|
||||
assert_eq!(r["corners"], json!(7));
|
||||
assert_eq!((cut(&s, star), cut(&s, home)), (vec![1, 3, 5, 7, 9], vec![0, 1, 2, 3, 4]));
|
||||
assert_eq!((drawn(&s, home, 0), drawn(&s, home, 2)), (3.0, 2.0));
|
||||
// On top of the named anchors and of `every`.
|
||||
s.execute("addon.pathtools.cornerRemove", &json!({})).unwrap();
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"ids": [home.0], "anchors": [1, 2, 3], "radius": 4, "minAngle": 80, "maxAngle": 100})).unwrap();
|
||||
assert_eq!(cut(&s, home), vec![1, 3]);
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"ids": [star.0], "radius": 4, "every": "even", "minAngle": 100})).unwrap();
|
||||
assert_eq!(cut(&s, star), vec![1, 3, 5, 7, 9]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_set_angle_range_on_a_three_anchor_path() {
|
||||
let mut s = session();
|
||||
// An open pen path of three anchors: one corner, of 90°.
|
||||
let id = line(&mut s, &[(10.0, 10.0), (60.0, 60.0), (110.0, 10.0)], false);
|
||||
select(&mut s, &[id]);
|
||||
let e = s.execute("addon.pathtools.cornerSet", &json!({"radius": 5, "maxAngle": 45})).unwrap_err().to_string();
|
||||
assert!(e.contains("none of the chosen corners has an angle from 0° to 45°"), "{e}");
|
||||
let r = s.execute("addon.pathtools.cornerSet", &json!({"radius": 5, "minAngle": 80, "maxAngle": 100})).unwrap();
|
||||
assert_eq!(r["corners"], json!(1));
|
||||
assert_eq!(cut(&s, id), vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_set_angle_range_refuses_bad_values_and_says_when_nothing_matches() {
|
||||
let mut s = session();
|
||||
let star = star_path(&mut s);
|
||||
select(&mut s, &[star]);
|
||||
let i = star.0;
|
||||
refuses(
|
||||
&mut s,
|
||||
"addon.pathtools.cornerSet",
|
||||
&[
|
||||
json!({"ids": [i], "radius": 5, "minAngle": -1}),
|
||||
json!({"ids": [i], "radius": 5, "maxAngle": 181}),
|
||||
json!({"ids": [i], "radius": 5, "minAngle": 1e308}),
|
||||
json!({"ids": [i], "radius": 5, "maxAngle": "sharp"}),
|
||||
json!({"ids": [i], "radius": 5, "minAngle": 120, "maxAngle": 60}),
|
||||
json!({"ids": [i], "radius": 5, "minAngle": 50, "maxAngle": 100}),
|
||||
json!({"ids": [i], "radius": 5, "every": "odd", "minAngle": 100}),
|
||||
],
|
||||
);
|
||||
let err = |s: &mut Session, p: Value| s.execute("addon.pathtools.cornerSet", &p).unwrap_err().to_string();
|
||||
let e = err(&mut s, json!({"ids": [i], "radius": 5, "maxAngle": 200}));
|
||||
assert!(e.contains("`maxAngle` must be from 0 to 180 degrees"), "{e}");
|
||||
let e = err(&mut s, json!({"ids": [i], "radius": 5, "minAngle": 120, "maxAngle": 60}));
|
||||
assert!(e.contains("`minAngle` (120°) can't be more than `maxAngle` (60°)"), "{e}");
|
||||
let e = err(&mut s, json!({"ids": [i], "radius": 5, "minAngle": 50, "maxAngle": 100}));
|
||||
assert!(e.contains("none of the chosen corners has an angle from 50° to 100°"), "{e}");
|
||||
let e = err(&mut s, json!({"ids": [i], "radius": 5, "every": "odd", "minAngle": 100}));
|
||||
assert!(e.contains("none of the chosen corners at an odd-numbered anchor (the 1st, 3rd, 5th…) has an angle from 100° to 180°"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_corner_tool_clicks_drags_and_removes_in_one_step_each() {
|
||||
let mut s = session();
|
||||
|
||||
Reference in New Issue
Block a user