Add angle adjust handles to partial-circle presets

Fixes #1094

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-21 05:04:04 +00:00
co-authored by Claude Haiku 5.5
parent a31b990d32
commit e89a893fe1
+163 -6
View File
@@ -33,7 +33,8 @@ pub struct SubPath {
}
/// A draggable adjust handle (the yellow dot). `pos = origin + dir * value`; dragging projects
/// the pointer onto `dir`.
/// the pointer onto `dir`. An `angle` handle instead measures the pointer's angle about `origin`
/// (1/60 000 degree, y down, 0° at +x).
#[derive(Clone, Debug, PartialEq)]
pub struct Handle {
pub index: usize,
@@ -42,14 +43,29 @@ pub struct Handle {
pub dir: Vec2,
pub min: f64,
pub max: f64,
pub angle: bool,
}
impl Handle {
fn new(index: usize, origin: Point, dir: Vec2, value: f64, min: f64, max: f64) -> Self {
Handle { index, pos: origin + dir * value, origin, dir, min, max }
Handle { index, pos: origin + dir * value, origin, dir, min, max, angle: false }
}
/// An angle handle on the `rx`×`ry` ellipse around `center` at visual angle `value`.
fn angle(index: usize, center: Point, rx: f64, ry: f64, value: f64) -> Self {
let t = vis(value / 60000.0, rx, ry).to_radians();
let pos = Point::new(center.x + rx * t.cos(), center.y + ry * t.sin());
Handle { index, pos, origin: center, dir: Vec2::ZERO, min: 0.0, max: 21600000.0, angle: true }
}
/// The adjust value for a pointer at `p` (shape-local space).
pub fn value_at(&self, p: Point) -> f64 {
if self.angle {
let d = p - self.origin;
if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 {
return self.min;
}
let v = (d.y.atan2(d.x).to_degrees().rem_euclid(360.0) * 60000.0).round();
return if v >= self.max { self.min } else { v };
}
let l2 = self.dir.hypot2();
if l2 < 1e-18 {
return self.min;
@@ -1082,7 +1098,15 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
b.m(p0.x, p0.y).arc(rx, ry, s0, sweep);
let pi = Point::new(w / 2.0 + irx * si.to_radians().cos(), h / 2.0 + iry * si.to_radians().sin());
b.l(pi.x, pi.y).arc(irx, iry, si, isweep).z();
simple(b.take(), full, w, h)
let mut g = simple(b.take(), full, w, h);
let c = Point::new(rx, ry);
g.handles.push(Handle::angle(0, c, rx - t / 2.0, ry - t / 2.0, a(0)));
// Thickness: slides inward from the outer edge along the end angle's ray.
let pe = Point::new(rx + rx * e0.to_radians().cos(), ry + ry * e0.to_radians().sin());
let u = (c - pe).normalize();
let u = if u.x.is_finite() && u.y.is_finite() { u } else { Vec2::ZERO };
g.handles.push(Handle::new(2, pe, u * (ss / 100000.0), a(2).clamp(0.0, 50000.0), 0.0, 50000.0));
g
}
"pie" | "chord" | "arc" => {
let (rx, ry) = (w / 2.0, h / 2.0);
@@ -1095,7 +1119,7 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
let p0 = Point::new(rx + rx * s.to_radians().cos(), ry + ry * s.to_radians().sin());
b.m(p0.x, p0.y).arc(rx, ry, s, sw);
let text = Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355);
match name {
let mut g = match name {
"pie" => {
b.l(rx, ry).z();
simple(b.take(), text, w, h)
@@ -1111,7 +1135,11 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
f.close_path();
Geometry { paths: vec![fill_only(f), stroke_only(open)], text_rect: text, handles: vec![], sites: mid_sites(w, h) }
}
}
};
let c = Point::new(rx, ry);
g.handles.push(Handle::angle(0, c, rx, ry, a(0)));
g.handles.push(Handle::angle(1, c, rx, ry, a(1)));
g
}
"teardrop" => {
let k = a(0).clamp(0.0, 200000.0) / 100000.0;
@@ -1821,7 +1849,7 @@ fn ellipse_sites(w: f64, h: f64) -> Vec<Point> {
fn tip_handle(w: f64, h: f64, tip: Point) -> Handle {
// A free 2-D handle is represented as index 0 with zero direction; callers move both values.
Handle { index: 0, pos: tip, origin: Point::new(w / 2.0, h / 2.0), dir: Vec2::new(w / 1e5, h / 1e5), min: -1e6, max: 1e6 }
Handle { index: 0, pos: tip, origin: Point::new(w / 2.0, h / 2.0), dir: Vec2::new(w / 1e5, h / 1e5), min: -1e6, max: 1e6, angle: false }
}
fn wedge_rect(w: f64, h: f64, a0: f64, a1: f64, r: f64) -> Geometry {
@@ -2252,6 +2280,135 @@ mod tests {
assert_eq!(h.value_at(Point::new(500.0, 0.0)), 50000.0);
}
#[test]
fn partial_circle_angle_handles_map_back() {
// Start 0° sits at the right of the ellipse, end 270° at its top.
for name in ["pie", "chord", "arc"] {
let g = build(name, 200.0, 100.0, &[0.0, 16200000.0]).unwrap();
assert_eq!(g.handles.iter().map(|h| h.index).collect::<Vec<_>>(), vec![0, 1], "{name}");
let (s, e) = (&g.handles[0], &g.handles[1]);
assert!((s.pos - Point::new(200.0, 50.0)).hypot() < 1e-6, "{name} {:?}", s.pos);
assert!((e.pos - Point::new(100.0, 0.0)).hypot() < 1e-6, "{name} {:?}", e.pos);
assert_eq!(s.value_at(s.pos), 0.0, "{name}");
assert_eq!(e.value_at(e.pos), 16200000.0, "{name}");
// Dragging to the bottom of the ellipse gives 90°.
assert_eq!(s.value_at(Point::new(100.0, 100.0)), 5400000.0, "{name}");
// Off-axis angles on a non-square ellipse: the visual 45° ray meets the 200×100
// ellipse at offset (√2000, √2000) from the centre.
let k = 2000f64.sqrt();
let g = build(name, 200.0, 100.0, &[2700000.0, 8100000.0]).unwrap();
let (s, e) = (&g.handles[0], &g.handles[1]);
assert!((s.pos - Point::new(100.0 + k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", s.pos);
assert!((e.pos - Point::new(100.0 - k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", e.pos);
for (deg, h) in [(45.0, s), (135.0, e)] {
let on = ((h.pos.x - 100.0) / 100.0).powi(2) + ((h.pos.y - 50.0) / 50.0).powi(2);
assert!((on - 1.0).abs() < 1e-9, "{name} {deg}° off ellipse: {:?}", h.pos);
assert!((h.value_at(h.pos) - deg * 60000.0).abs() <= 1.0, "{name} {deg}°: {}", h.value_at(h.pos));
}
}
for deg in [45.0, 135.0, 200.0, 315.0] {
let g = build("blockArc", 200.0, 100.0, &[deg * 60000.0, 0.0, 25000.0]).unwrap();
let start = g.handles.iter().find(|h| h.index == 0).unwrap();
// Thickness 25 → the handle runs on the 87.5×37.5 mid-ring ellipse.
let on = ((start.pos.x - 100.0) / 87.5).powi(2) + ((start.pos.y - 50.0) / 37.5).powi(2);
assert!((on - 1.0).abs() < 1e-9, "blockArc {deg}° off ring: {:?}", start.pos);
let d = start.pos - Point::new(100.0, 50.0);
assert!((d.y.atan2(d.x).to_degrees().rem_euclid(360.0) - deg).abs() < 1e-6, "blockArc {deg}°: {:?}", start.pos);
assert!((start.value_at(start.pos) - deg * 60000.0).abs() <= 1.0, "blockArc {deg}°");
}
let g = build("blockArc", 200.0, 100.0, &[10800000.0, 0.0, 25000.0]).unwrap();
let start = g.handles.iter().find(|h| h.index == 0).unwrap();
assert_eq!(start.value_at(start.pos), 10800000.0);
let thick = g.handles.iter().find(|h| h.index == 2).unwrap();
assert!((thick.value_at(thick.pos) - 25000.0).abs() <= 1.0);
}
#[test]
fn presets_with_adjust_values_expose_handles() {
// Presets whose adjust values have no handle of their own yet.
const NO_HANDLES: &[&str] = &[
"curvedConnector3",
"snip2SameRect",
"snip2DiagRect",
"snipRoundRect",
"round1Rect",
"round2SameRect",
"round2DiagRect",
"teardrop",
"halfFrame",
"corner",
"diagStripe",
"foldedCorner",
"smileyFace",
"moon",
"gear6",
"gear9",
"bracketPair",
"bracePair",
"leftBracket",
"rightBracket",
"leftBrace",
"rightBrace",
"leftRightArrow",
"upDownArrow",
"quadArrow",
"leftRightUpArrow",
"bentArrow",
"uturnArrow",
"curvedRightArrow",
"curvedLeftArrow",
"curvedUpArrow",
"curvedDownArrow",
"leftUpArrow",
"bentUpArrow",
"circularArrow",
"leftCircularArrow",
"leftRightCircularArrow",
"swooshArrow",
"rightArrowCallout",
"downArrowCallout",
"leftArrowCallout",
"upArrowCallout",
"leftRightArrowCallout",
"upDownArrowCallout",
"quadArrowCallout",
"mathPlus",
"mathMinus",
"mathMultiply",
"mathDivide",
"mathEqual",
"mathNotEqual",
"ribbon2",
"ribbon",
"ellipseRibbon2",
"ellipseRibbon",
"leftRightRibbon",
"verticalScroll",
"horizontalScroll",
"wave",
"doubleWave",
"borderCallout1",
"borderCallout2",
"borderCallout3",
"accentCallout1",
"accentCallout2",
"accentCallout3",
"callout1",
"callout2",
"callout3",
"accentBorderCallout1",
"accentBorderCallout2",
"accentBorderCallout3",
];
for p in CATALOG {
if p.defaults.is_empty() || NO_HANDLES.contains(&p.name) {
continue;
}
let g = build(p.name, 200.0, 100.0, &[]).unwrap();
assert!(!g.handles.is_empty(), "{} has adjust values but no handles", p.name);
}
}
#[test]
fn rect_area_and_ellipse_area() {
let g = build("rect", 100.0, 50.0, &[]).unwrap();