vectorcraft/crates/tools/src/shape.rs

360 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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. The start point and
//! the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering before the press too.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect};
use crate::guides::{DrawSnap, Leave, square};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
pub struct ShapeTool {
id: &'static str,
start: Option<Point>,
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,
snap: DrawSnap,
}
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,
snap: DrawSnap::default(),
}
}
fn label(&self) -> &'static str {
match self.id {
"roundedRectangle" => "Rounded Rectangle",
"ellipse" => "Ellipse",
"polygon" => "Polygon",
"star" => "Star",
"lineSegment" => "Line",
_ => "Rectangle",
}
}
/// How the dragged point keeps to the start: a polygon's or star's radius goes anywhere, a
/// line is a segment (Shift: 45° steps), the rest are boxes (Shift: a square).
fn leave(&self, cx: &ToolContext, start: Point, m: Mods) -> Option<Leave> {
match self.id {
"polygon" | "star" => None,
"lineSegment" => Some(Leave::segment(cx, start, m.shift)),
_ => Some(Leave::diagonal(start, m.shift)),
}
}
/// The command for a drag from `start` to `p` (snapped: a line's end already keeps Shift's
/// angle).
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) };
("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 d = if m.shift { square(p - start) } else { p - start };
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<Action> {
match ev.kind {
PointerKind::Move => {
self.snap.hover(cx, ev.pos, &[], None);
vec![]
}
PointerKind::Down => {
let p = self.snap.press(cx, ev.pos, &[], None);
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 = self.snap.drag(cx, ev.pos, self.leave(cx, s, ev.mods).as_ref());
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.snap.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<Action> {
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;
self.snap.clear();
vec![Action::Cancel]
} else {
vec![]
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
if let Some(s) = self.start.filter(|_| self.began && cx.measurement_labels) {
// A box's size as drawn (from its centre with Alt, square with Shift).
let (w, h) = match self.id {
"polygon" | "star" | "lineSegment" => ((self.last - s).x.abs(), (self.last - s).y.abs()),
_ => {
let r = drag_rect(s, self.last, self.mods);
(r.width(), r.height())
}
};
o.push(Overlay::Measure { p: self.last, text: cx.size_label(w, h) });
}
o
}
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));
}
/// The start and the dragged corner land on another object's anchors (#506), hovering shows
/// it before the press, and the measurement label gives the size drawn.
#[test]
fn corners_snap_to_smart_guides() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeTool::new("rectangle");
let anchor = |o: &[Overlay], at: Point| o.iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "anchor" && *p == at));
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 98.0, 102.0));
assert!(anchor(&t.overlays(&cx), Point::new(100.0, 100.0)));
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 98.0, 102.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 198.0, 202.0));
assert_eq!(a[1], Action::Preview("shape.rectangle".into(), json!({"x": 100.0, "y": 100.0, "width": 100.0, "height": 100.0})));
let o = t.overlays(&cx);
assert!(anchor(&o, Point::new(200.0, 200.0)), "{o:?}");
assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(100.0, 100.0))));
// Alt draws from the centre: the label gives the whole size.
let alt = Mods { alt: true, ..Mods::default() };
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 198.0, 202.0).with_mods(alt));
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(200.0, 200.0))));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 198.0, 202.0));
assert!(t.overlays(&cx).is_empty());
// A line with Shift keeps to 45° steps, sliding into line with the rect's right side.
let mut t = ShapeTool::new("lineSegment");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
let shift = Mods { shift: true, ..Mods::default() };
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 203.0, 302.0).with_mods(shift));
assert!(matches!(&a[..], [_, Action::Preview(_, v)] if v["x2"] == 200.0 && (v["y2"].as_f64().unwrap() - 300.0).abs() < 1e-9), "{a:?}");
}
}