Set Corners: round only the corners in an angle range (#5545)

Squashed from #5737 onto main.
This commit is contained in:
Don Omondi
2026-11-05 11:04:52 +07:00
committed by bflatastic
parent 077ef54d70
commit 62c6d42dff
34 changed files with 264 additions and 23 deletions
@@ -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();