diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index 9dc8edf258..390a31bf5f 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -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, diff --git a/crates/engine/src/cmd/vector_cmds.rs b/crates/engine/src/cmd/vector_cmds.rs index 4539b22c4f..b122b20d19 100644 --- a/crates/engine/src/cmd/vector_cmds.rs +++ b/crates/engine/src/cmd/vector_cmds.rs @@ -98,7 +98,7 @@ pub fn specs() -> Vec { "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 diff --git a/crates/ui-egui/src/tools/corner_radius.rs b/crates/ui-egui/src/tools/corner_radius.rs index f5ec3192e9..2381e623b3 100644 --- a/crates/ui-egui/src/tools/corner_radius.rs +++ b/crates/ui-egui/src/tools/corner_radius.rs @@ -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 }, } /// 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 { - 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 { 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, } diff --git a/crates/ui-egui/src/tools/corner_radius/tests.rs b/crates/ui-egui/src/tools/corner_radius/tests.rs index 252e9ce587..904eb9ff59 100644 --- a/crates/ui-egui/src/tools/corner_radius/tests.rs +++ b/crates/ui-egui/src/tools/corner_radius/tests.rs @@ -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); +} diff --git a/crates/ui-egui/src/tools/vector_ui/options.rs b/crates/ui-egui/src/tools/vector_ui/options.rs index fae9f13269..39519be0a6 100644 --- a/crates/ui-egui/src/tools/vector_ui/options.rs +++ b/crates/ui-egui/src/tools/vector_ui/options.rs @@ -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") { diff --git a/crates/vector/src/shapes.rs b/crates/vector/src/shapes.rs index 0b7e65a48d..607531d5b3 100644 --- a/crates/vector/src/shapes.rs +++ b/crates/vector/src/shapes.rs @@ -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 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::*; diff --git a/crates/vector/src/star_outer_corner_radius_tests.rs b/crates/vector/src/star_outer_corner_radius_tests.rs new file mode 100644 index 0000000000..6718498c1a --- /dev/null +++ b/crates/vector/src/star_outer_corner_radius_tests.rs @@ -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::>(), [true, false, true, false, true, false]); +}