//! Shape tools: Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment. //! //! Drag draws (Shift constrains to square/circle/45°, Alt draws from the centre, Space held moves //! the shape being drawn); a click without a drag asks the UI for the size dialog (like //! Illustrator). ↑/↓ during a polygon/star drag change the side/point count. use serde_json::{Value, json}; use vectorcraft_geom::{Point, Rect}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; pub struct ShapeTool { id: &'static str, start: Option, last: Point, mods: Mods, began: bool, pub sides: u32, pub points: u32, pub corner_radius: f64, /// Star inner radius as a fraction of the outer radius. pub star_ratio: f64, guides: Vec, } impl ShapeTool { pub fn new(id: &str) -> Self { let id: &'static str = match id { "roundedRectangle" => "roundedRectangle", "ellipse" => "ellipse", "polygon" => "polygon", "star" => "star", "lineSegment" => "lineSegment", _ => "rectangle", }; Self { id, start: None, last: Point::ZERO, mods: Mods::default(), began: false, sides: 6, points: 5, corner_radius: 12.0, star_ratio: 0.5, guides: vec![], } } fn label(&self) -> &'static str { match self.id { "roundedRectangle" => "Rounded Rectangle", "ellipse" => "Ellipse", "polygon" => "Polygon", "star" => "Star", "lineSegment" => "Line", _ => "Rectangle", } } /// The command for the current drag. pub fn command(&self, start: Point, p: Point, m: Mods) -> (String, Value) { match self.id { "polygon" | "star" => { let v = p - start; let r = v.hypot().max(0.01); let rot = if m.shift { 0.0 } else { (v.atan2().to_degrees() + 90.0) % 360.0 }; if self.id == "polygon" { ("shape.polygon".into(), json!({ "cx": start.x, "cy": start.y, "radius": r, "sides": self.sides, "rotation": rot })) } else { ( "shape.star".into(), json!({ "cx": start.x, "cy": start.y, "radius1": r, "radius2": r * self.star_ratio, "points": self.points, "rotation": rot }), ) } } "lineSegment" => { let (a, b) = if m.alt { (start - (p - start), p) } else { (start, p) }; let b = if m.shift { a + vectorcraft_geom::constrain_angle(b - a, 45.0) } else { b }; ("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y })) } _ => { let r = drag_rect(start, p, m); let cmd = if self.id == "ellipse" { "shape.ellipse" } else { "shape.rectangle" }; let mut v = json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }); if self.id == "roundedRectangle" { v["radius"] = json!(self.corner_radius); } (cmd.into(), v) } } } } /// Space held once a shape drag has begun moves the shape being drawn instead of sizing it: `start` /// moves as far as the pointer did since `last`, which becomes the pointer. pub(crate) fn space_moves(start: &mut Point, last: &mut Point, ev: &PointerEvent, began: bool) { if ev.mods.space && began { *start += ev.pos - *last; } *last = ev.pos; } /// The rectangle a drag from `start` to `p` draws: Shift makes it a square, Alt draws it from its /// centre. pub(crate) fn drag_rect(start: Point, p: Point, m: Mods) -> Rect { let mut d = p - start; if m.shift { let s = d.x.abs().max(d.y.abs()); d = vectorcraft_geom::Vec2::new(s * d.x.signum(), s * d.y.signum()); } if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) } } impl Tool for ShapeTool { fn id(&self) -> &'static str { self.id } fn busy(&self) -> bool { self.start.is_some() } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { PointerKind::Down => { let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]); self.start = Some(p); self.last = ev.pos; self.began = false; vec![] } PointerKind::Drag => { let Some(mut s) = self.start else { return vec![] }; let (pos, g) = crate::guides::snap_draw(cx, ev.pos, &[]); self.guides = g; let ev = &PointerEvent { pos, ..*ev }; space_moves(&mut s, &mut self.last, ev, self.began); self.start = Some(s); self.mods = ev.mods; let mut out = vec![]; if !self.began { if ev.pos.distance(s) < cx.tol(2.0) { return out; } self.began = true; out.push(Action::Begin(self.label().into())); } let (c, v) = self.command(s, ev.pos, ev.mods); out.push(Action::Preview(c, v)); out } PointerKind::Up => { let Some(s) = self.start.take() else { return vec![] }; self.guides.clear(); if self.began { self.began = false; vec![Action::Commit] } else { vec![Action::Dialog(self.id.into(), json!({ "x": s.x, "y": s.y }))] } } _ => vec![], } } fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { let Some(s) = self.start.filter(|_| self.began) else { return vec![] }; let changed = match (self.id, key) { ("polygon", ToolKey::Up) => { self.sides = (self.sides + 1).min(1000); true } ("polygon", ToolKey::Down) => { self.sides = self.sides.saturating_sub(1).max(3); true } ("star", ToolKey::Up) => { self.points = (self.points + 1).min(1000); true } ("star", ToolKey::Down) => { self.points = self.points.saturating_sub(1).max(3); true } ("roundedRectangle", ToolKey::Up) => { self.corner_radius += 1.0; true } ("roundedRectangle", ToolKey::Down) => { self.corner_radius = (self.corner_radius - 1.0).max(0.0); true } _ => false, }; if changed { let (c, v) = self.command(s, self.last, self.mods); vec![Action::Preview(c, v)] } else if key == ToolKey::Escape { self.start = None; self.began = false; vec![Action::Cancel] } else { vec![] } } fn overlays(&self, cx: &ToolContext) -> Vec { match self.start { Some(s) if self.began => { let d = self.last - s; let mut o = self.guides.clone(); o.push(Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) }); o } _ => vec![], } } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { Cursor::Crosshair } fn options(&self) -> Value { json!({ "sides": self.sides, "points": self.points, "cornerRadius": self.corner_radius, "starRatio": self.star_ratio }) } fn set_option(&mut self, key: &str, v: &Value) { match key { "sides" => self.sides = v.as_u64().unwrap_or(6).clamp(3, 1000) as u32, "points" => self.points = v.as_u64().unwrap_or(5).clamp(3, 1000) as u32, "cornerRadius" => self.corner_radius = v.as_f64().unwrap_or(12.0).max(0.0), "starRatio" => self.star_ratio = v.as_f64().unwrap_or(0.5).clamp(0.01, 1.0), _ => {} } } } #[cfg(test)] mod tests { use super::*; use crate::testutil::*; use vectorcraft_doc::Selection; #[test] fn rect_drag() { let (d, _) = doc_with_rect(); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); let mut t = ShapeTool::new("rectangle"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 30.0)); assert_eq!(a[0], Action::Begin("Rectangle".into())); assert_eq!(a[1], Action::Preview("shape.rectangle".into(), json!({"x": 10.0, "y": 10.0, "width": 40.0, "height": 20.0}))); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 50.0, 30.0)), vec![Action::Commit]); } #[test] fn space_moves_the_shape_being_drawn() { let (d, _) = doc_with_rect(); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); let space = Mods { space: true, ..Default::default() }; let mut t = ShapeTool::new("rectangle"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 120.0)); // Space held: the 100 × 70 rectangle follows the pointer. let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 170.0).with_mods(space)); assert_eq!(a.last(), Some(&Action::Preview("shape.rectangle".into(), json!({"x": 150.0, "y": 100.0, "width": 100.0, "height": 70.0})))); // Let go of Space: it grows again, from where it was moved to. let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 270.0, 190.0)); assert_eq!(a.last(), Some(&Action::Preview("shape.rectangle".into(), json!({"x": 150.0, "y": 100.0, "width": 120.0, "height": 90.0})))); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 270.0, 190.0)), vec![Action::Commit]); // A star (drawn from its centre) moves its centre. let mut t = ShapeTool::new("star"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 50.0)); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 60.0).with_mods(space)); assert!(matches!(a.last(), Some(Action::Preview(_, v)) if v["cx"] == 60.0 && v["cy"] == 60.0), "{a:?}"); } #[test] fn shift_square_alt_center() { let t = ShapeTool::new("ellipse"); let (c, v) = t.command(Point::new(0.0, 0.0), Point::new(10.0, 4.0), Mods { shift: true, alt: true, ..Default::default() }); assert_eq!(c, "shape.ellipse"); assert_eq!(v, json!({"x": -10.0, "y": -10.0, "width": 20.0, "height": 20.0})); } #[test] fn click_opens_dialog() { let (d, _) = doc_with_rect(); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); let mut t = ShapeTool::new("star"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); assert_eq!( t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), vec![Action::Dialog("star".into(), json!({"x": 10.0, "y": 10.0}))] ); } #[test] fn arrow_keys_change_sides() { let (d, _) = doc_with_rect(); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); let mut t = ShapeTool::new("polygon"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 10.0)); let a = t.key(&cx, ToolKey::Up, Mods::default()); assert!(matches!(&a[0], Action::Preview(_, v) if v["sides"] == 7)); } }