//! Region and end-point commands behind the functions on xfig's free mouse //! buttons (plan W13, W14): update, move and point deletion by region, //! points at the ends of a line, reversing arrows, breaking all levels of a //! compound, aligning to a reference, circles through three points and //! pasting the clipboard as text. //! //! A region is the rectangle between two corners `(x1, y1)` and `(x2, y2)` //! in Fig units, or a lasso `polygon` (W15). It takes the objects completely inside it, on visible //! depths, that the mode's object mask lets through (`tools::mode_mask`). use super::object::{insert_point, remove_point}; use super::shape::{added, make_ellipse, make_text, style_from}; use super::{has_objects, ids_value, num, opt_bool, opt_str, point_value, CmdResult, Command}; use crate::tools::{mode_mask, ToolKind}; use crate::{Engine, EngineError, Region}; use rustfig_geom::{Affine, Point, Rect}; use rustfig_model::{EllipseKind, Object, ObjectId, ObjectKind, PolyKind}; use serde_json::{json, Value}; use std::sync::Arc; pub(super) fn commands() -> Vec { vec![ Command { id: "object.update_region", label: "Update Region", menu: "", shortcut: "", params: "{\"x1\", \"y1\", \"x2\", \"y2\" | \"polygon\": [[x,y]…] (lasso)} — every object completely inside the rectangle (visible depths) takes the indicator's attributes, as object.update does; Update mode, right button", mutates: true, enabled: has_objects, run: update_region, }, Command { id: "transform.move_region", label: "Move Region", menu: "", shortcut: "", params: "{\"x1\", \"y1\", \"x2\", \"y2\" | \"polygon\", \"dx\", \"dy\"} — move every object completely inside the rectangle (visible depths); Move mode, right button", mutates: true, enabled: has_objects, run: move_region, }, Command { id: "point.delete_region", label: "Delete Points in Region", menu: "", shortcut: "", params: "{\"x1\", \"y1\", \"x2\", \"y2\" | \"polygon\", \"id\"?} — delete the points inside the rectangle from one line, polygon or spline: the given one, else the front-most with a point inside; Delete point mode, middle button", mutates: true, enabled: has_objects, run: delete_points_region, }, Command { id: "point.add_end", label: "Add Point at End", menu: "", shortcut: "", params: "{\"id\", \"x\", \"y\", \"end\"?: nearest|start|end} — add a point at (x, y) beyond the first or last point of an open line or spline (default: the end nearer to the point); Add point mode, middle button", mutates: true, enabled: has_objects, run: add_end, }, Command { id: "object.reverse_arrows", label: "Reverse Arrows", menu: "", shortcut: "", params: "{\"ids\"?: [id]} — swap the forward and backward arrow of lines, open splines and arcs; Arrow mode, right button", mutates: true, enabled: |e| !e.selection().is_empty(), run: reverse_arrows, }, Command { id: "object.break_all", label: "Break All Levels", menu: "", shortcut: "", params: "{\"ids\"?: [id], \"reappend\"?: bool (as object.ungroup)} — break compounds and the compounds inside them down to single objects; Break mode, right button", mutates: true, enabled: |e| !e.selection().is_empty(), run: break_all, }, Command { id: "arrange.align_to", label: "Align to Object", menu: "", shortcut: "", params: "{\"reference\": id, \"ids\"?: [id], \"h\"?: none|left|center|right, \"v\"?: none|top|center|bottom} — move the objects so that their boxes line up with the reference object's box (defaults from the indicator); Align mode, right button", mutates: true, enabled: has_objects, run: align_to, }, Command { id: "shape.circle3", label: "Circle through Three Points", menu: "", shortcut: "", params: "{\"points\": [[x,y] x3], \"kind\"?: radius|diameter} + style — Circle modes, right button", mutates: true, enabled: |_| true, run: circle3, }, Command { id: "edit.paste_text", label: "Paste as Text", menu: "", shortcut: "", params: "{\"x\", \"y\"} — new text at (x, y) from the text objects in the clipboard, one line each; Text mode, middle button", mutates: true, enabled: |e| e.clipboard_text().is_some(), run: paste_text, }, ] } /// The region of the parameters: `"polygon": [[x, y], …]` (a lasso, at /// least three points), else the rectangle `x1, y1, x2, y2`. fn region(p: &Value) -> Result { if p.get("polygon").is_some_and(|v| !v.is_null()) { let pts = super::points(p, "polygon")?; if pts.len() < 3 { return Err(EngineError::bad_params("a lasso needs at least three points")); } return Ok(Region::Lasso(pts)); } let a = Point::new(num(p, "x1")?, num(p, "y1")?); let b = Point::new(num(p, "x2")?, num(p, "y2")?); Ok(Region::Rect(Rect::new(a, b))) } fn update_region(e: &mut Engine, p: &Value) -> CmdResult { let r = region(p)?; let ids = e.objects_in_region(&r, mode_mask(ToolKind::Update, 90.0)); if ids.is_empty() { return Err(EngineError::bad_params("no objects completely inside the region")); } e.set_selection(ids.clone()); super::object::update(e, &json!({ "ids": ids_value(&ids) })) } fn move_region(e: &mut Engine, p: &Value) -> CmdResult { let r = region(p)?; let (dx, dy) = (num(p, "dx")?, num(p, "dy")?); let ids = e.objects_in_region(&r, mode_mask(ToolKind::Move, 90.0)); if ids.is_empty() { return Err(EngineError::bad_params("no objects completely inside the region")); } e.set_selection(ids.clone()); super::transform::apply(e, &json!({ "ids": ids_value(&ids) }), Affine::translate(dx, dy), false) } fn delete_points_region(e: &mut Engine, p: &Value) -> CmdResult { let r = region(p)?; let mask = mode_mask(ToolKind::DeletePoint, 90.0); let inside = |o: &Object| -> Vec { match o.points() { Some(pts) if mask.admits(o) && !matches!(o.kind, ObjectKind::Arc(_)) => pts .iter() .enumerate() .filter(|(_, q)| r.contains(**q)) .map(|(i, _)| i) .collect(), _ => Vec::new(), } }; let id = match p.get("id") { None | Some(Value::Null) => { // The front-most (lowest depth, last drawn) object with a point inside. let mut best: Option<(i32, usize, ObjectId)> = None; for (i, o) in e.doc().objects.iter().enumerate() { if !e.is_visible(o) || inside(o).is_empty() { continue; } let better = match best { None => true, Some((d, bi, _)) => o.depth() < d || (o.depth() == d && i > bi), }; if better { best = Some((o.depth(), i, o.id)); } } best.map(|(_, _, id)| id) .ok_or_else(|| EngineError::bad_params("no line or spline has a point inside the region"))? } Some(v) => { let id = ObjectId( v.as_u64() .ok_or_else(|| EngineError::bad_params("id must be an object id"))?, ); if e.doc().get(id).is_none() { return Err(EngineError::not_found(format!("no top-level object with id {}", id.0))); } id } }; let idx = match e.doc().get(id) { Some(o) => inside(o), None => Vec::new(), }; if idx.is_empty() { return Err(EngineError::bad_params("the object has no point inside the region")); } let o = e .doc_mut() .get_mut(id) .ok_or_else(|| EngineError::not_found("object vanished"))?; // From the last point to the first, so the indices stay valid. for i in idx.iter().rev() { remove_point(o, *i)?; } e.set_selection(vec![id]); Ok(json!({"id": id.0, "count": idx.len()})) } fn add_end(e: &mut Engine, p: &Value) -> CmdResult { let id = ObjectId(num(p, "id")?.round().max(0.0) as u64); let pt = point_value(&json!([num(p, "x")?, num(p, "y")?]))?; let which = opt_str(p, "end")?.unwrap_or("nearest"); let o = e .doc_mut() .get_mut(id) .ok_or_else(|| EngineError::not_found(format!("no top-level object with id {}", id.0)))?; let (first, last, len) = match &o.kind { ObjectKind::Polyline(pl) if pl.kind == PolyKind::Polyline => { (pl.points.first(), pl.points.last(), pl.points.len()) } ObjectKind::Spline(s) if !s.closed => (s.points.first(), s.points.last(), s.points.len()), _ => { return Err(EngineError::bad_params( "points can only be added at the ends of an open line or spline", )) } }; let (Some(first), Some(last)) = (first.copied(), last.copied()) else { return Err(EngineError::bad_params("the object has no points")); }; let at_start = match which { "start" => true, "end" => false, "nearest" => pt.dist(first) < pt.dist(last), other => return Err(EngineError::bad_params(format!("unknown end {other:?}"))), }; let o = Arc::make_mut( e.doc_mut() .objects .iter_mut() .find(|x| x.id == id) .ok_or_else(|| EngineError::not_found("object vanished"))?, ); insert_point(o, if at_start { 0 } else { len }, pt)?; e.set_selection(vec![id]); Ok(json!({"id": id.0, "index": if at_start { 0 } else { len }})) } fn reverse_arrows(e: &mut Engine, p: &Value) -> CmdResult { let ids = e.target_ids(p)?; if ids.is_empty() { return Err(EngineError::bad_params("no objects selected")); } let mut n = 0; for o in e.doc_mut().objects.iter_mut() { if !ids.contains(&o.id) { continue; } let has = matches!(o.arrows(), Some((f, b)) if f.is_some() || b.is_some()); if !has { continue; } if let Some((f, b)) = Arc::make_mut(o).arrows_mut() { std::mem::swap(f, b); n += 1; } } if n == 0 { return Err(EngineError::bad_params("none of the objects has an arrow")); } Ok(json!({"count": n})) } fn break_all(e: &mut Engine, p: &Value) -> CmdResult { fn flatten(o: Object, out: &mut Vec) { match o.kind { ObjectKind::Compound(c) => { for child in c.objects { flatten(child, out); } } _ => out.push(o), } } let ids = e.target_ids(p)?; let doc = e.doc_mut(); let mut out = Vec::new(); let mut freed = Vec::new(); let mut broke = 0; for o in std::mem::take(&mut doc.objects) { if matches!(o.kind, ObjectKind::Compound(_)) && ids.contains(&o.id) { broke += 1; let mut parts = Vec::new(); flatten(Arc::unwrap_or_clone(o), &mut parts); for part in parts { freed.push(part.id); out.push(Arc::new(part)); } } else { out.push(o); } } doc.objects = out; if broke == 0 { return Err(EngineError::bad_params("no compound selected")); } if opt_bool(p, "reappend")?.unwrap_or(false) { super::transform::move_to_end(e, &freed); } e.set_selection(freed.clone()); Ok(json!({"ids": ids_value(&freed)})) } fn align_to(e: &mut Engine, p: &Value) -> CmdResult { let rid = ObjectId(num(p, "reference")?.round().max(0.0) as u64); let reference = e .doc() .get(rid) .ok_or_else(|| EngineError::bad_params(format!("no top-level object with id {}", rid.0)))? .geometry_bbox(); let h = opt_str(p, "h")?.map_or_else(|| e.indicator.align_h.clone(), str::to_string); let v = opt_str(p, "v")?.map_or_else(|| e.indicator.align_v.clone(), str::to_string); if !matches!( h.as_str(), "none" | "" | "left" | "center" | "right" | "distribute_centres" | "distribute_edges" | "abut" ) { return Err(EngineError::bad_params(format!("unknown horizontal alignment {h:?}"))); } if !matches!( v.as_str(), "none" | "" | "top" | "center" | "bottom" | "distribute_centres" | "distribute_edges" | "abut" ) { return Err(EngineError::bad_params(format!("unknown vertical alignment {v:?}"))); } if reference.is_empty() { return Err(EngineError::bad_params("the reference object has no extent")); } let ids: Vec = e.target_ids(p)?.into_iter().filter(|i| *i != rid).collect(); if ids.is_empty() { return Err(EngineError::bad_params("no objects to align")); } let mut moves = Vec::new(); for id in ids { let Some(o) = e.doc().get(id) else { continue }; let b = o.geometry_bbox(); if b.is_empty() { continue; } let dx = match h.as_str() { "left" => reference.min.x - b.min.x, "center" => reference.center().x - b.center().x, "right" => reference.max.x - b.max.x, _ => 0.0, }; let dy = match v.as_str() { "top" => reference.min.y - b.min.y, "center" => reference.center().y - b.center().y, "bottom" => reference.max.y - b.max.y, _ => 0.0, }; if dx != 0.0 || dy != 0.0 { moves.push((id, dx, dy)); } } let moved = moves.len(); // Smart links: the ends attached to each object follow it. crate::links::translate_each(e, &moves); Ok(json!({"count": moved})) } /// The circle through three points: centre and radius. pub(crate) fn circumcircle(a: Point, b: Point, c: Point) -> Option<(Point, f64)> { let d = 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)); if !d.is_finite() || d.abs() < 1e-9 { return None; } let (a2, b2, c2) = (a.x * a.x + a.y * a.y, b.x * b.x + b.y * b.y, c.x * c.x + c.y * c.y); let ux = (a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d; let uy = (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d; let center = Point::new(ux, uy); let r = center.dist(a); (center.is_finite() && r.is_finite() && (1.0..1.0e9).contains(&r)).then_some((center, r)) } fn circle3(e: &mut Engine, p: &Value) -> CmdResult { let pts = super::points(p, "points")?; let [a, b, c] = pts.as_slice() else { return Err(EngineError::bad_params( "a circle through points needs exactly three points", )); }; let (center, r) = circumcircle(*a, *b, *c).ok_or_else(|| { EngineError::bad_params("the three points are on a line, or the circle is too small or too large") })?; let kind = match opt_str(p, "kind")?.unwrap_or("radius") { "radius" => EllipseKind::CircleRadius, "diameter" => EllipseKind::CircleDiameter, other => return Err(EngineError::bad_params(format!("unknown circle kind {other:?}"))), }; let style = style_from(e, p)?; added(e, make_ellipse(style, center, r, r, 0.0, kind)) } fn paste_text(e: &mut Engine, p: &Value) -> CmdResult { let origin = Point::new(num(p, "x")?, num(p, "y")?); let lines = e .clipboard_text() .ok_or_else(|| EngineError::bad_params("the clipboard holds no text"))?; let step = if e.indicator.text_step.is_finite() { e.indicator.text_step.clamp(0.0, 100.0) } else { 1.0 }; let size = e.indicator.font_size * rustfig_geom::FIG_PER_POINT * rustfig_text::LINE_SPACING * step; let down = Affine::rotate(e.indicator.text_angle.to_radians()).apply_vec(Point::new(0.0, size)); let mut ids = Vec::new(); for (i, line) in lines.into_iter().enumerate() { let t = make_text(e, origin + down * i as f64, line); ids.push(e.add_object(Object::new(ObjectKind::Text(t)))); } e.set_selection(ids.clone()); Ok(json!({"ids": ids_value(&ids)})) } .