mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-12 21:23:02 +00:00
Polygon tool: a rounded star rounds only its outer points (#13308)
live_path rounds a star's outer points and leaves its inner points sharp corners, as the reference application does: a 5-point star at Star Ratio 50 % with corner radius 10 has 15 anchors instead of 20. Plain polygons keep rounding every vertex. The on-canvas corner radius widget shows knobs at the outer points only and takes its largest radius from them, so it previews the same path as shape.create.
This commit is contained in:
1 parent
c1b22bdaae
commit
cb5298e9d4
7 files changed
+131
-20
No files matched your search
@@ -303,8 +303,8 @@ pub enum LiveShape {
|
||||
rect: [f64; 4],
|
||||
},
|
||||
/// Regular polygon or star inscribed in `rect`; `star_ratio` = inner/outer radius (1 = polygon).
|
||||
/// `corner_radius` rounds every vertex (0 = sharp), each within what its edges allow; the
|
||||
/// Triangle tool draws a 3-sided one.
|
||||
/// `corner_radius` rounds every vertex of a polygon and the outer points of a star (0 = sharp),
|
||||
/// each within what its edges allow; the Triangle tool draws a 3-sided one.
|
||||
Polygon {
|
||||
rect: [f64; 4],
|
||||
sides: u32,
|
||||
|
||||
@@ -98,7 +98,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Shape Layer",
|
||||
["Layer", "New"],
|
||||
leak(format!(
|
||||
r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"cornerRadius":px=0 (polygon/star: rounds every vertex; the Triangle tool is a 3-sided polygon),"from":[x,y],"to":[x,y],"weight":px=1 (line),"arrowStart":bool=false,"arrowEnd":bool=false,"arrowWidth":px=52,"arrowLength":px=50,"arrowConcavity":0..100=0 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"##
|
||||
r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"cornerRadius":px=0 (polygon: rounds every vertex, star: its outer points; the Triangle tool is a 3-sided polygon),"from":[x,y],"to":[x,y],"weight":px=1 (line),"arrowStart":bool=false,"arrowEnd":bool=false,"arrowWidth":px=52,"arrowLength":px=50,"arrowConcavity":0..100=0 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"##
|
||||
)),
|
||||
has_doc,
|
||||
shape_create
|
||||
|
||||
@@ -26,8 +26,9 @@ const MIN_INSET: f64 = 10.0;
|
||||
enum Geom {
|
||||
/// `[x, y, w, h]` with a positive size.
|
||||
Rect([f64; 4]),
|
||||
/// The polygon's parameters and its sharp vertices, in path order.
|
||||
Polygon { rect: [f64; 4], sides: u32, star_ratio: f64, vertices: Vec<[f64; 2]> },
|
||||
/// The polygon's parameters, its sharp vertices in path order, and the indices of those
|
||||
/// that take the radius (a star's outer points only).
|
||||
Polygon { rect: [f64; 4], sides: u32, star_ratio: f64, vertices: Vec<[f64; 2]>, rounded: Vec<usize> },
|
||||
}
|
||||
|
||||
/// A selected live shape with corners: its layer, its geometry, and its radii (a rectangle's four
|
||||
@@ -52,7 +53,7 @@ impl Target {
|
||||
fn corners(&self) -> usize {
|
||||
match &self.geom {
|
||||
Geom::Rect(_) => 4,
|
||||
Geom::Polygon { vertices, .. } => vertices.len(),
|
||||
Geom::Polygon { rounded, .. } => rounded.len(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,27 +73,29 @@ impl Target {
|
||||
};
|
||||
Some(Corner { vertex, step: d })
|
||||
}
|
||||
Geom::Polygon { vertices, .. } => {
|
||||
Geom::Polygon { vertices, rounded, .. } => {
|
||||
let f = self.fillet(i, 1.0)?;
|
||||
let sin = f.half_angle.sin();
|
||||
(sin > 1e-9).then(|| Corner { vertex: vertices.get(i).copied().unwrap_or(f.centre), step: [f.bisector[0] / sin, f.bisector[1] / sin] })
|
||||
let vertex = rounded.get(i).and_then(|&v| vertices.get(v)).copied().unwrap_or(f.centre);
|
||||
(sin > 1e-9).then(|| Corner { vertex, step: [f.bisector[0] / sin, f.bisector[1] / sin] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A polygon vertex's rounding with radius `r`.
|
||||
/// Corner `i`'s rounding with radius `r` (a polygon's).
|
||||
fn fillet(&self, i: usize, r: f64) -> Option<shapes::Fillet> {
|
||||
let Geom::Polygon { vertices, .. } = &self.geom else { return None };
|
||||
let Geom::Polygon { vertices, rounded, .. } = &self.geom else { return None };
|
||||
let n = vertices.len();
|
||||
let v = *rounded.get(i)?;
|
||||
let at = |k: usize| vertices.get(k % n).copied();
|
||||
shapes::fillet(at(i + n - 1)?, at(i)?, at(i + 1)?, r)
|
||||
shapes::fillet(at(v + n - 1)?, at(v)?, at(v + 1)?, r)
|
||||
}
|
||||
|
||||
/// The largest radius every corner of the shape can take.
|
||||
fn max_radius(&self) -> f64 {
|
||||
match &self.geom {
|
||||
Geom::Rect(r) => r[2].min(r[3]) / 2.0,
|
||||
Geom::Polygon { vertices, .. } => (0..vertices.len()).filter_map(|i| self.fillet(i, 1.0e12)).map(|f| f.radius).fold(f64::INFINITY, f64::min),
|
||||
Geom::Polygon { rounded, .. } => (0..rounded.len()).filter_map(|i| self.fillet(i, 1.0e12)).map(|f| f.radius).fold(f64::INFINITY, f64::min),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,8 +178,9 @@ fn target(layer: u64, live: &LiveShape) -> Option<Target> {
|
||||
return None;
|
||||
}
|
||||
let sharp = shapes::polygon(rect[0], rect[1], rect[2], rect[3], sides, star_ratio);
|
||||
let vertices = sharp.subpaths.first()?.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect();
|
||||
Some(Target { layer, geom: Geom::Polygon { rect, sides, star_ratio, vertices }, radii: [corner_radius.max(0.0); 4] })
|
||||
let vertices: Vec<[f64; 2]> = sharp.subpaths.first()?.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect();
|
||||
let rounded = (0..vertices.len()).filter(|&i| shapes::polygon_rounds_vertex(star_ratio, i)).collect();
|
||||
Some(Target { layer, geom: Geom::Polygon { rect, sides, star_ratio, vertices, rounded }, radii: [corner_radius.max(0.0); 4] })
|
||||
}
|
||||
LiveShape::Ellipse { .. } | LiveShape::Line { .. } | LiveShape::ArrowLine { .. } => None,
|
||||
}
|
||||
|
||||
@@ -267,3 +267,27 @@ fn rectangles_and_triangles_selected_together_take_the_same_radius() {
|
||||
assert_eq!(rr, [4.0; 4]);
|
||||
assert!((corner_radius_of(&app, tri) - 4.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_star_shows_widgets_at_its_outer_points_only() {
|
||||
// #13308: a star's inner points stay sharp, so they have no widget.
|
||||
let live = LiveShape::Polygon { rect: [40.0, 40.0, 120.0, 100.0], sides: 5, star_ratio: 0.5, corner_radius: 6.0 };
|
||||
let t = super::target(1, &live).unwrap();
|
||||
assert_eq!(t.corners(), 5);
|
||||
let sharp = &photocraft_vector::shapes::polygon(40.0, 40.0, 120.0, 100.0, 5, 0.5).subpaths[0].knots;
|
||||
for i in 0..5 {
|
||||
let v = sharp[2 * i].anchor;
|
||||
assert_eq!(t.corner(i).unwrap().vertex, [v.x, v.y], "corner {i} is outer point {i}");
|
||||
}
|
||||
// The widgets preview the outline `shape.create` makes with the same radius.
|
||||
assert_eq!(t.path(t.radii), photocraft_vector::shapes::live_path(&live));
|
||||
// The largest radius is the one every outer point can take.
|
||||
let outer_most = (0..5).filter_map(|i| t.fillet(i, 1.0e12)).map(|f| f.radius).fold(f64::INFINITY, f64::min);
|
||||
assert!(outer_most.is_finite() && t.max_radius() == outer_most);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_polygon_shows_a_widget_at_every_vertex() {
|
||||
let live = LiveShape::Polygon { rect: [40.0, 40.0, 120.0, 100.0], sides: 6, star_ratio: 1.0, corner_radius: 6.0 };
|
||||
assert_eq!(super::target(1, &live).unwrap().corners(), 6);
|
||||
}
|
||||
@@ -402,7 +402,7 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra
|
||||
}
|
||||
}
|
||||
photocraft_doc::vector::LiveShape::Polygon { sides, star_ratio, corner_radius, .. } => {
|
||||
// One radius rounds every vertex of a triangle, polygon or star, as in Photoshop.
|
||||
// One radius rounds every vertex of a triangle or polygon, or a star's outer points, as in Photoshop.
|
||||
ui.horizontal(|ui| {
|
||||
let mut r = *corner_radius as f32;
|
||||
if num(ui, tl!("Corner radius"), &mut r, 0.0..=100000.0, "px") {
|
||||
|
||||
@@ -99,7 +99,7 @@ pub fn polygon(x: f64, y: f64, w: f64, h: f64, sides: u32, star_ratio: f64) -> P
|
||||
let sides = sides.clamp(3, 100) as usize;
|
||||
let (rx, ry) = (w / 2.0, h / 2.0);
|
||||
let (cx, cy) = (x + rx, y + ry);
|
||||
let star = star_ratio > 0.0 && star_ratio < 1.0;
|
||||
let star = is_star(star_ratio);
|
||||
let count = if star { sides * 2 } else { sides };
|
||||
let pts: Vec<(f64, f64)> = (0..count)
|
||||
.map(|i| {
|
||||
@@ -111,6 +111,18 @@ pub fn polygon(x: f64, y: f64, w: f64, h: f64, sides: u32, star_ratio: f64) -> P
|
||||
Path::new(vec![Subpath::polygon(&pts)])
|
||||
}
|
||||
|
||||
/// Does `star_ratio` make [`polygon`] a star (inner points between its outer ones)?
|
||||
fn is_star(star_ratio: f64) -> bool {
|
||||
star_ratio > 0.0 && star_ratio < 1.0
|
||||
}
|
||||
|
||||
/// Does vertex `i` of [`polygon`] with `star_ratio` take the corner radius? Every vertex of a
|
||||
/// plain polygon; only a star's outer points (even `i`), its inner points stay sharp, as in
|
||||
/// Photoshop.
|
||||
pub fn polygon_rounds_vertex(star_ratio: f64, i: usize) -> bool {
|
||||
!is_star(star_ratio) || i.is_multiple_of(2)
|
||||
}
|
||||
|
||||
/// A rounded vertex: see [`fillet`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Fillet {
|
||||
@@ -168,6 +180,12 @@ pub fn fillet(prev: [f64; 2], v: [f64; 2], next: [f64; 2], r: f64) -> Option<Fil
|
||||
/// circular arc of radius `r` tangent to both its edges, each capped as [`fillet`] does, like
|
||||
/// Photoshop's rounded triangles and polygons. `r <= 0` leaves it sharp.
|
||||
pub fn round_corners(path: Path, r: f64) -> Path {
|
||||
round_corners_where(path, r, |_| true)
|
||||
}
|
||||
|
||||
/// [`round_corners`] for the vertices `rounds` picks by their index in the subpath; the others
|
||||
/// stay sharp corners.
|
||||
pub fn round_corners_where(path: Path, r: f64, rounds: impl Fn(usize) -> bool) -> Path {
|
||||
if r.is_nan() || r <= 0.0 {
|
||||
return path;
|
||||
}
|
||||
@@ -183,7 +201,7 @@ pub fn round_corners(path: Path, r: f64) -> Path {
|
||||
let mut knots = Vec::with_capacity(n * 2);
|
||||
for i in 0..n {
|
||||
let (prev, v, next) = (pt(i + n - 1), pt(i), pt(i + 1));
|
||||
let Some(f) = fillet(prev, v, next, r) else {
|
||||
let Some(f) = rounds(i).then(|| fillet(prev, v, next, r)).flatten() else {
|
||||
knots.push(Knot::corner(v[0], v[1]));
|
||||
continue;
|
||||
};
|
||||
@@ -280,7 +298,7 @@ pub fn live_path(s: &LiveShape) -> Path {
|
||||
LiveShape::Rect { rect: [x, y, w, h], radii } => rounded_rect(*x, *y, *w, *h, *radii),
|
||||
LiveShape::Ellipse { rect: [x, y, w, h] } => ellipse(*x, *y, *w, *h),
|
||||
LiveShape::Polygon { rect: [x, y, w, h], sides, star_ratio, corner_radius } => {
|
||||
round_corners(polygon(*x, *y, *w, *h, *sides, *star_ratio), *corner_radius)
|
||||
round_corners_where(polygon(*x, *y, *w, *h, *sides, *star_ratio), *corner_radius, |i| polygon_rounds_vertex(*star_ratio, i))
|
||||
}
|
||||
LiveShape::Line { from, to, weight } => line((from[0], from[1]), (to[0], to[1]), *weight),
|
||||
LiveShape::ArrowLine { from, to, weight, start, end, width, length, concavity } => {
|
||||
@@ -368,9 +386,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_star_rounds_its_inner_vertices_too() {
|
||||
fn a_star_rounds_its_outer_vertices_only() {
|
||||
let p = live_path(&LiveShape::Polygon { rect: [0.0, 0.0, 200.0, 200.0], sides: 5, star_ratio: 0.5, corner_radius: 6.0 });
|
||||
assert_eq!(p.subpaths[0].knots.len(), 20);
|
||||
assert_eq!(p.subpaths[0].knots.len(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -407,6 +425,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "star_outer_corner_radius_tests.rs"]
|
||||
mod star_outer_corner_radius_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod line_arrow_6408_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! A rounded star rounds only its outer points; its inner points stay sharp corners, as in
|
||||
//! Photoshop (#13308). Plain polygons keep rounding every vertex.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn star(corner_radius: f64) -> Path {
|
||||
live_path(&LiveShape::Polygon { rect: [400.0, 300.0, 200.0, 100.0], sides: 5, star_ratio: 0.5, corner_radius })
|
||||
}
|
||||
|
||||
fn has_handles(k: &Knot) -> bool {
|
||||
k.in_ctrl != k.anchor || k.out_ctrl != k.anchor
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rounded_five_point_star_has_two_anchors_per_outer_point_and_sharp_inner_points() {
|
||||
let knots = &star(10.0).subpaths[0].knots;
|
||||
assert_eq!(knots.len(), 15);
|
||||
let sharp = &polygon(400.0, 300.0, 200.0, 100.0, 5, 0.5).subpaths[0].knots;
|
||||
// Each outer point becomes an arc (two anchors with a handle each), then the inner point
|
||||
// follows as a corner anchor exactly where the sharp star has it.
|
||||
for (n, chunk) in knots.chunks(3).enumerate() {
|
||||
let [from, to, inner] = chunk else { panic!("15 knots in threes") };
|
||||
assert!(has_handles(from) && has_handles(to), "outer point {n} is rounded");
|
||||
assert!(!has_handles(inner), "inner point {n} is a corner");
|
||||
assert_eq!(inner.anchor, sharp[2 * n + 1].anchor, "inner point {n} stays put");
|
||||
}
|
||||
assert_eq!(knots.iter().filter(|k| has_handles(k)).count(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outer_point_is_rounded_by_the_arc_the_radius_asks_for() {
|
||||
let knots = &star(10.0).subpaths[0].knots;
|
||||
let sharp: Vec<[f64; 2]> = polygon(400.0, 300.0, 200.0, 100.0, 5, 0.5).subpaths[0].knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect();
|
||||
for n in 0..5 {
|
||||
let v = 2 * n;
|
||||
let f = fillet(sharp[(v + 9) % 10], sharp[v], sharp[v + 1], 10.0).unwrap();
|
||||
assert_eq!([knots[3 * n].anchor.x, knots[3 * n].anchor.y], f.from, "outer point {n}");
|
||||
assert_eq!([knots[3 * n + 1].anchor.x, knots[3 * n + 1].anchor.y], f.to, "outer point {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_polygon_still_rounds_every_vertex() {
|
||||
for star_ratio in [1.0, 0.0, 1.5] {
|
||||
let p = live_path(&LiveShape::Polygon { rect: [400.0, 300.0, 200.0, 100.0], sides: 5, star_ratio, corner_radius: 10.0 });
|
||||
assert_eq!(p, round_corners(polygon(400.0, 300.0, 200.0, 100.0, 5, star_ratio), 10.0), "star ratio {star_ratio}");
|
||||
assert_eq!(p.subpaths[0].knots.len(), 10);
|
||||
assert!(p.subpaths[0].knots.iter().all(has_handles));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_star_without_a_radius_stays_sharp() {
|
||||
assert_eq!(star(0.0), polygon(400.0, 300.0, 200.0, 100.0, 5, 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_stars_outer_vertices_take_the_radius() {
|
||||
assert!((0..10).all(|i| polygon_rounds_vertex(1.0, i)));
|
||||
assert_eq!((0..6).map(|i| polygon_rounds_vertex(0.5, i)).collect::<Vec<_>>(), [true, false, true, false, true, false]);
|
||||
}
|
||||
Reference in new issue
Block a user