Files
bflatasticandClaude Opus 5.5 1d14f337db Fix #511: Live Corners on any path's corners (stars, polygons, pen paths)
Corner widgets only ever showed on live rectangles: CornerWidgets::of bailed
out unless the selection's live shape was LiveShape::Rectangle, and
object.setLiveShape only edited rectangle radii. A star is a plain path, so it
had no widgets with either selection tool.

- geom::corners: the corners of any path (anchors without handles between two
  straight sides) and the path with them cut round, inverted round or
  chamfered, each corner a circle tangent to both sides, its radius no larger
  than takes the cut halfway along its shorter side. Rectangles now cut their
  corners with it (bit-identical anchors and layout).
- doc::corners::LiveCorners/set_corners: one model for a live rectangle's,
  a live polygon's (new radii/kinds) and any other path's corners (new
  LiveShape::Path keeping the uncut outline, plain again once nothing is cut);
  radii scale with transforms (Scale Corners) and stay circular.
- object.setLiveShape takes anchor indices for `corners` on any path; polygons
  keep a shared radius as their sides change.
- Widgets: Direct Selection on any path, Selection on live rectangles and
  polygons; placed on each corner's bisector, the drag follows it, the red
  limit outline marks corners at their own limit; the angle preference and
  View > Hide Corner Widget apply. Corners dialog and the Properties and
  Transform panels' Corner Radius (now on polygons too) work on them.
- Tests for the star, the polygon and a pen path in geom, doc, tools, engine
  and ui-egui; docs and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:05:41 +07:00

124 lines
4.0 KiB
Rust

//! VectorCraft geometry kernel.
//!
//! The editable path model is anchor based (like every Illustrator tool thinks about paths):
//! a [`PathData`] is a list of [`SubPath`]s, each a list of [`Anchor`]s with absolute in/out
//! handle positions. Rendering and algorithms convert to [`kurbo::BezPath`].
//!
//! Coordinates are document points (1/72 in), y pointing down.
#![forbid(unsafe_code)]
pub mod arc;
pub mod bez;
pub mod corners;
pub mod hit;
pub mod path;
pub mod projective;
pub mod recognize;
pub mod shapes;
pub mod snap;
pub use arc::ArcPath;
pub use kurbo;
pub use kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, Rect, Shape, Size, Vec2};
pub use path::{Anchor, AnchorKind, FillRule, PathData, SubPath};
pub use projective::Homography;
/// Tolerance used when comparing handle positions to anchor positions.
pub const EPS: f64 = 1e-9;
/// Union of two optional rects.
pub fn union_opt(a: Option<Rect>, b: Option<Rect>) -> Option<Rect> {
match (a, b) {
(Some(a), Some(b)) => Some(a.union(b)),
(a, None) => a,
(None, b) => b,
}
}
/// Normalize an angle in degrees to (-180, 180].
pub fn normalize_deg(mut a: f64) -> f64 {
a %= 360.0;
if a <= -180.0 {
a += 360.0;
} else if a > 180.0 {
a -= 360.0;
}
a
}
/// Snap a vector to the nearest multiple of `step_deg` (used for Shift-constrained drags).
pub fn constrain_angle(v: Vec2, step_deg: f64) -> Vec2 {
constrain_angle_from(v, step_deg, 0.0)
}
/// [`constrain_angle`] in steps counted from `base_deg` (the Constrain Angle preference,
/// counter-clockwise as seen on the y-down page).
pub fn constrain_angle_from(v: Vec2, step_deg: f64, base_deg: f64) -> Vec2 {
let len = v.hypot();
if len < EPS {
return v;
}
let (step, base) = (step_deg.to_radians(), -base_deg.to_radians());
let a = base + ((v.y.atan2(v.x) - base) / step).round() * step;
Vec2::new(a.cos() * len, a.sin() * len)
}
/// Rect from two corner points in any order.
pub fn rect_from_points(a: Point, b: Point) -> Rect {
Rect::from_points(a, b)
}
/// The 9 reference points of a rect (Illustrator's reference-point locator), row-major from top-left.
pub fn reference_point(r: Rect, index: usize) -> Point {
let xs = [r.x0, r.center().x, r.x1];
let ys = [r.y0, r.center().y, r.y1];
Point::new(xs[index % 3], ys[(index / 3).min(2)])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn angle_normalization() {
assert_eq!(normalize_deg(190.0), -170.0);
assert_eq!(normalize_deg(-190.0), 170.0);
assert_eq!(normalize_deg(180.0), 180.0);
assert_eq!(normalize_deg(720.0), 0.0);
}
#[test]
fn constrain_45() {
let v = constrain_angle(Vec2::new(10.0, 1.0), 45.0);
assert!((v.y).abs() < 1e-9);
let v = constrain_angle(Vec2::new(10.0, 9.0), 45.0);
assert!((v.x - v.y).abs() < 1e-9);
}
#[test]
fn constrain_from_a_base_angle() {
// 30° base: 45° steps land on 30°, 75°, … (counter-clockwise on the page, y down).
let deg = |v: Vec2| (-v.y).atan2(v.x).to_degrees();
assert!((deg(constrain_angle_from(Vec2::new(10.0, -4.0), 45.0, 30.0)) - 30.0).abs() < 1e-9);
assert!((deg(constrain_angle_from(Vec2::new(3.0, -10.0), 45.0, 30.0)) - 75.0).abs() < 1e-9);
assert!((deg(constrain_angle_from(Vec2::new(10.0, 1.0), 45.0, 30.0)) + 15.0).abs() < 1e-9);
}
#[test]
fn reference_points() {
let r = Rect::new(0.0, 0.0, 10.0, 20.0);
assert_eq!(reference_point(r, 0), Point::new(0.0, 0.0));
assert_eq!(reference_point(r, 4), Point::new(5.0, 10.0));
assert_eq!(reference_point(r, 8), Point::new(10.0, 20.0));
}
#[test]
fn union_options() {
let a = Rect::new(0.0, 0.0, 1.0, 1.0);
let b = Rect::new(2.0, 2.0, 3.0, 3.0);
assert_eq!(union_opt(Some(a), Some(b)), Some(Rect::new(0.0, 0.0, 3.0, 3.0)));
assert_eq!(union_opt(None, Some(b)), Some(b));
assert_eq!(union_opt(None, None), None);
}
}