//! Flare, Reshape, Shaper and Graph tools. //! //! Flare: drag sets the centre and its size (click without dragging opens the Flare Tool Options), //! then a click sets the end point of the rings; the rest comes from the tool's options (the Flare //! Tool Options, which last). Reshape: drag a point of a selected path; the engine's //! `path.reshape` adds an anchor there if needed and moves the neighbourhood smoothly. use serde_json::{Value, json}; use vectorcraft_geom::{Point, Vec2}; use crate::distort::{BLUE, ellipse_path}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; pub fn create(id: &str) -> Option> { Some(match id { "flare" => Box::new(FlareTool::default()), "reshape" => Box::new(ReshapeTool::default()), "shaper" => Box::new(ShaperTool::default()), id if is_graph_tool(id) => Box::new(GraphTool::new(id)), _ => return None, }) } /// Whether `id` is one of the graph tools (`columnGraph`, `pieGraph`…). pub fn is_graph_tool(id: &str) -> bool { id.ends_with("Graph") && vectorcraft_doc::GraphKind::parse(id).is_some() } /// The Flare Tool Options as `shape.flare` takes them: (key, default, least, most). The diameter /// and the rings' path length are in points, the counts whole, the direction in degrees, the /// rest percentages. pub const FLARE_OPTIONS: [(&str, f64, f64, f64); 12] = [ ("diameter", 100.0, 0.0, 1000.0), ("opacity", 50.0, 0.0, 100.0), ("brightness", 30.0, 0.0, 100.0), ("growth", 20.0, 0.0, 300.0), ("fuzziness", 50.0, 0.0, 100.0), ("rays", 15.0, 0.0, 50.0), ("longest", 300.0, 0.0, 1000.0), ("rayFuzziness", 100.0, 0.0, 100.0), ("pathLength", 300.0, 0.0, 1000.0), ("rings", 10.0, 0.0, 50.0), ("largest", 50.0, 0.0, 250.0), ("direction", 45.0, 0.0, 360.0), ]; /// Flare Tool: centre drag, then a click for the rings' end point. pub struct FlareTool { /// Pressed at (centre) and the current drag point. drag: Option<(Point, Point)>, /// Centre placed, waiting for the end-point click: (centre, diameter). placed: Option<(Point, f64)>, hover: Point, began: bool, /// Ray count changed with ↑/↓ during the gesture (None = the options' count). rays: Option, /// The options' values, in [`FLARE_OPTIONS`] order. values: [f64; 12], /// Whether the flare has rays and rings (the options' Rays and Rings checkboxes). rays_on: bool, rings_on: bool, } impl Default for FlareTool { fn default() -> Self { Self { drag: None, placed: None, hover: Point::ZERO, began: false, rays: None, values: FLARE_OPTIONS.map(|(_, default, _, _)| default), rays_on: true, rings_on: true, } } } /// Whether Flare Tool Options value `key` is a count (whole). fn is_count(key: &str) -> bool { matches!(key, "rays" | "rings") } /// Flare Tool Options value `x` of `key` as JSON: a count as a whole number. fn option_json(key: &str, x: f64) -> Value { if is_count(key) { json!(x as u64) } else { json!(x) } } /// `shape.flare`'s params for a flare at `c` drawn with the Flare Tool Options `o` (as the tool /// reports them, [`Tool::options`]): with Rays or Rings off it has none. pub fn flare_params(o: &Value, c: Point) -> Value { let mut v = json!({ "cx": c.x, "cy": c.y }); for (key, default, ..) in FLARE_OPTIONS { v[key] = o.get(key).cloned().unwrap_or_else(|| option_json(key, default)); } for (on, key) in [("raysOn", "rays"), ("ringsOn", "rings")] { if o.get(on).and_then(Value::as_bool) == Some(false) { v[key] = json!(0); } } v } impl FlareTool { /// `shape.flare`'s params: the options, with the dragged centre and diameter, the ray count /// ↑/↓ chose and the rings' end point once clicked. fn params(&self, c: Point, diameter: f64, end: Option) -> Value { let mut v = flare_params(&self.options(), c); v["diameter"] = json!(diameter.max(1.0)); if let Some(r) = self.rays.filter(|_| self.rays_on) { v["rays"] = json!(r); } if let Some(e) = end { v["x2"] = json!(e.x); v["y2"] = json!(e.y); } v } } impl Tool for FlareTool { fn id(&self) -> &'static str { "flare" } fn busy(&self) -> bool { self.drag.is_some() || self.placed.is_some() } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { self.hover = ev.pos; match ev.kind { PointerKind::Down => { if self.placed.is_none() { self.drag = Some((ev.pos, ev.pos)); self.rays = None; } vec![] } PointerKind::Drag => { if let Some((c, _)) = self.drag { self.drag = Some((c, ev.pos)); if !self.began && ev.pos.distance(c) >= cx.tol(3.0) { self.began = true; return vec![ Action::Begin("Flare".into()), Action::Preview("shape.flare".into(), self.params(c, 2.0 * ev.pos.distance(c), None)), ]; } if self.began { return vec![Action::Preview("shape.flare".into(), self.params(c, 2.0 * ev.pos.distance(c), None))]; } } else if let Some((c, d)) = self.placed { return vec![Action::Preview("shape.flare".into(), self.params(c, d, Some(ev.pos)))]; } vec![] } PointerKind::Up => { if let Some((c, d)) = self.placed.take() { self.began = false; return vec![Action::Preview("shape.flare".into(), self.params(c, d, Some(ev.pos))), Action::Commit]; } let Some((c, p)) = self.drag.take() else { return vec![] }; if self.began { // Keep the interaction open: the next click places the end point. self.placed = Some((c, 2.0 * p.distance(c))); vec![] } else { vec![Action::Dialog("flare".into(), json!({ "x": c.x, "y": c.y }))] } } PointerKind::Move => { if let Some((c, d)) = self.placed { return vec![Action::Preview("shape.flare".into(), self.params(c, d, Some(ev.pos)))]; } vec![] } _ => vec![], } } fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { // ↑/↓ while drawing add/remove rays. if matches!(key, ToolKey::Up | ToolKey::Down) && self.began && self.rays_on { let r = self.rays.unwrap_or_else(|| self.options()["rays"].as_u64().unwrap_or(0)); self.rays = Some(if key == ToolKey::Up { (r + 1).min(50) } else { r.saturating_sub(1) }); let preview = match (self.drag, self.placed) { (Some((c, p)), _) => self.params(c, 2.0 * p.distance(c), None), (_, Some((c, d))) => self.params(c, d, Some(self.hover)), _ => return vec![], }; return vec![Action::Preview("shape.flare".into(), preview)]; } if key == ToolKey::Escape && self.busy() { self.rays = None; self.drag = None; self.placed = None; let began = std::mem::take(&mut self.began); return if began { vec![Action::Cancel] } else { vec![] }; } vec![] } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { // Switching away after the centre drag keeps the flare with the rings its options give. self.drag = None; if self.placed.take().is_some() && std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![] } } fn overlays(&self, _cx: &ToolContext) -> Vec { match (self.drag, self.placed) { (Some((c, p)), _) => { vec![Overlay::Path { path: ellipse_path(c, p.distance(c), p.distance(c), 0.0), color: BLUE, width: 1.0, dashed: false }] } (_, Some((c, _))) => vec![Overlay::Line { a: c, b: self.hover, color: BLUE, dashed: true }], _ => vec![], } } fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { Cursor::Crosshair } fn options(&self) -> Value { let mut v = json!({ "raysOn": self.rays_on, "ringsOn": self.rings_on }); for ((key, ..), x) in FLARE_OPTIONS.iter().zip(self.values) { v[*key] = option_json(key, x); } v } fn set_option(&mut self, key: &str, v: &Value) { match key { "raysOn" => self.rays_on = v.as_bool().unwrap_or(true), "ringsOn" => self.rings_on = v.as_bool().unwrap_or(true), _ => { let Some((slot, (_, default, lo, hi))) = self.values.iter_mut().zip(FLARE_OPTIONS).find(|(_, (k, ..))| *k == key) else { return }; let x = v.as_f64().filter(|x| x.is_finite()).unwrap_or(default).clamp(lo, hi); *slot = if is_count(key) { x.round() } else { x }; } } } } /// Reshape Tool: drag a point on a selected path. #[derive(Default)] pub struct ReshapeTool { /// (path, grab point) while dragging. grab: Option<(vectorcraft_doc::NodeId, Point)>, began: bool, } impl ReshapeTool { /// The selected path under `p` (Reshape works on selected paths only, like Illustrator). fn hit(cx: &ToolContext, p: Point) -> Option<(vectorcraft_doc::NodeId, Point)> { let tol = cx.tol(4.0); cx.selection.objects.iter().copied().filter(|id| cx.doc.is_editable(*id)).find_map(|id| { let pd = cx.doc.node(id)?.path_data()?; let (_, _, _, q, d) = pd.nearest(p)?; (d <= tol).then_some((id, q)) }) } } impl Tool for ReshapeTool { fn id(&self) -> &'static str { "reshape" } fn busy(&self) -> bool { self.grab.is_some() } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { PointerKind::Down => { self.grab = Self::hit(cx, ev.pos); self.began = false; vec![] } PointerKind::Drag => { let Some((id, at)) = self.grab else { return vec![] }; let mut d: Vec2 = ev.pos - at; if ev.mods.shift { d = vectorcraft_geom::constrain_angle(d, 45.0); } let mut out = vec![]; if !self.began { if d.hypot() < cx.tol(2.0) { return out; } self.began = true; out.push(Action::Begin("Reshape".into())); } out.push(Action::Preview( "path.reshape".into(), json!({ "id": id.0, "x": at.x, "y": at.y, "dx": d.x, "dy": d.y, "tol": cx.tol(4.0) }), )); out } PointerKind::Up => { self.grab = None; if std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![] } } _ => vec![], } } fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Escape && self.grab.take().is_some() && std::mem::take(&mut self.began) { return vec![Action::Cancel]; } vec![] } fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { if self.grab.is_some() || Self::hit(cx, p).is_some() { Cursor::Move } else { Cursor::Arrow } } } /// Shaper Tool: draw a rough shape and it becomes a clean live shape (rectangle, ellipse, /// triangle/polygon, line); scribble over art to delete it. #[derive(Default)] pub struct ShaperTool { points: Vec, } impl Tool for ShaperTool { fn id(&self) -> &'static str { "shaper" } fn busy(&self) -> bool { !self.points.is_empty() } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { use vectorcraft_geom::recognize::{Recognized, recognize}; match ev.kind { PointerKind::Down => { self.points = vec![ev.pos]; vec![] } PointerKind::Drag => { if !self.points.is_empty() && self.points.len() < 4095 && self.points.last().is_none_or(|l| l.distance(ev.pos) >= cx.tol(1.5)) { self.points.push(ev.pos); } vec![] } PointerKind::Up => { // A fast final segment may arrive only on release; it still closes the stroke. if !self.points.is_empty() && self.points.last().is_none_or(|p| *p != ev.pos) { self.points.push(ev.pos); } let pts = std::mem::take(&mut self.points); if pts.first().is_some_and(|first| pts.iter().all(|p| p.distance(*first) < cx.tol(3.0))) { return vec![Action::Exec("shaper.select".into(), json!({"point": [ev.pos.x, ev.pos.y]}))]; } let r = |x: vectorcraft_geom::Rect, rotation: f64| json!({ "x": x.x0, "y": x.y0, "width": x.width(), "height": x.height(), "rotation": rotation }); match recognize(&pts) { Some(Recognized::Line { a, b }) => vec![Action::Exec("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y }))], Some(Recognized::Rectangle { rect, rotation }) => vec![Action::Exec("shape.rectangle".into(), r(rect, rotation))], Some(Recognized::Ellipse { rect, rotation }) => vec![Action::Exec("shape.ellipse".into(), r(rect, rotation))], Some(Recognized::Polygon { center, radius, sides, rotation }) => { vec![Action::Exec( "shape.polygon".into(), json!({ "cx": center.x, "cy": center.y, "radius": radius, "sides": sides, "rotation": rotation }), )] } Some(Recognized::Scribble(_)) => vec![Action::Exec( "shaper.scribble".into(), json!({"points": pts.iter().map(|p| [p.x, p.y]).collect::>(), "tolerance": cx.tol(4.0)}), )], None => vec![], } } PointerKind::DoubleClick => vec![ Action::Exec("shaper.select".into(), json!({"point": [ev.pos.x, ev.pos.y], "source": true})), Action::SwitchTool("selection".into()), ], _ => vec![], } } fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Escape { self.points.clear(); } vec![] } fn overlays(&self, _cx: &ToolContext) -> Vec { if self.points.len() < 2 { return vec![]; } vec![Overlay::Path { path: crate::draw2::polyline(&self.points), color: BLUE, width: 1.5, dashed: false }] } fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { Cursor::Crosshair } } /// Graph tools: drag the plot rectangle (Shift = square, Alt = from the centre); a click opens the /// size dialog. A new graph opens the Graph Data dialog. pub struct GraphTool { id: &'static str, start: Option, began: bool, } impl GraphTool { fn new(id: &str) -> Self { let id = crate::catalog::tool_info(id).map(|t| t.id).unwrap_or("columnGraph"); Self { id, start: None, began: false } } fn kind(&self) -> &'static str { vectorcraft_doc::GraphKind::parse(self.id).unwrap_or_default().id() } fn params(&self, s: Point, p: Point, m: Mods) -> Value { let mut d = p - s; if m.shift { let k = d.x.abs().max(d.y.abs()); d = Vec2::new(k * d.x.signum(), k * d.y.signum()); } let r = if m.alt { vectorcraft_geom::Rect::from_points(s - d, s + d) } else { vectorcraft_geom::Rect::from_points(s, s + d) }; json!({ "type": self.kind(), "x": r.x0, "y": r.y0, "width": r.width().max(1.0), "height": r.height().max(1.0) }) } } impl Tool for GraphTool { 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 => { self.start = Some(ev.pos); self.began = false; vec![] } PointerKind::Drag => { let Some(s) = self.start else { return vec![] }; let mut out = vec![]; if !self.began { if ev.pos.distance(s) < cx.tol(3.0) { return out; } self.began = true; out.push(Action::Begin("Graph".into())); } out.push(Action::Preview("graph.create".into(), self.params(s, ev.pos, ev.mods))); out } PointerKind::Up => { let Some(s) = self.start.take() else { return vec![] }; if std::mem::take(&mut self.began) { vec![ Action::Preview("graph.create".into(), self.params(s, ev.pos, ev.mods)), Action::Commit, Action::Dialog("graphData".into(), json!({})), ] } else { vec![Action::Dialog("graph".into(), json!({ "x": s.x, "y": s.y, "type": self.kind() }))] } } _ => vec![], } } fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Escape && self.start.take().is_some() && std::mem::take(&mut self.began) { return vec![Action::Cancel]; } vec![] } fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { Cursor::Crosshair } } #[cfg(test)] mod tests { use super::*; /// The Flare Tool Options: kept within their ranges, and what the next drag draws with. #[test] fn flare_options_last_and_draw_the_next_flare() { let mut t = FlareTool::default(); assert_eq!(t.options()["rays"], json!(15)); for (k, v) in [("diameter", json!(5000)), ("rays", json!(7.6)), ("opacity", json!("x")), ("ringsOn", json!(false)), ("nope", json!(1))] { t.set_option(k, &v); } let o = t.options(); assert_eq!( (o["diameter"].as_f64(), o["rays"].as_u64(), o["opacity"].as_f64(), o["ringsOn"].as_bool()), (Some(1000.0), Some(8), Some(50.0), Some(false)) ); assert!(o.get("nope").is_none()); // A drag sets the centre and diameter; the options give the rest, rings off. let p = t.params(Point::new(10.0, 20.0), 40.0, None); assert_eq!((p["cx"].as_f64(), p["diameter"].as_f64(), p["rays"].as_u64(), p["rings"].as_u64()), (Some(10.0), Some(40.0), Some(8), Some(0))); assert_eq!(p["longest"].as_f64(), Some(300.0)); t.set_option("raysOn", &json!(false)); assert_eq!(t.params(Point::ZERO, 1.0, None)["rays"].as_u64(), Some(0)); } #[test] fn create_covers_both() { assert_eq!(create("flare").unwrap().id(), "flare"); assert_eq!(create("reshape").unwrap().id(), "reshape"); assert_eq!(create("shaper").unwrap().id(), "shaper"); assert!(create("pen").is_none()); for g in ["columnGraph", "stackedColumnGraph", "barGraph", "stackedBarGraph", "lineGraph", "areaGraph", "scatterGraph", "pieGraph", "radarGraph"] { assert_eq!(create(g).unwrap().id(), g); } } }