//! The measure modes (plan D5): the angle between three points or at a //! corner of a line or an arc, the length of lines, boxes, arcs and //! ellipses with a running total, and their area with a running total //! (signed or not). Nothing here changes the drawing: no undo step, no //! change to the selection; the result goes to the message panel. // // Ported from xfig src/e_measure.c (`init_anglemeas_object`, // `anglemeas_line`, `anglemeas_arc`, `anglemeas_third`, // `init_lenmeas_object`, `clear_lenmeas_memory`, `init_areameas_object`, // `clear_areameas_memory`), src/u_geom.c (`compute_3p_angle`, // `compute_line_angle`, `compute_arc_angle`, `compute_poly_length`, // `compute_arc_length`, `compute_poly_area`, `compute_arc_area`, // `compute_ellipse_area`) and src/w_msgpanel.c (`lenmeas_msg`, // `areameas_msg`, `make_dimension_string`) (xfig licence, see // LICENSE-xfig). use super::{opt_bool, opt_num, CmdResult, Command}; use crate::view::length_string; use crate::{Engine, EngineError}; use rustfig_geom::{Point, FIG_PER_CM, FIG_PER_INCH, FIG_PER_STYLE_UNIT}; use rustfig_model::{Arc, Ellipse, Object, ObjectId, ObjectKind, PolyKind, Polyline, Units}; use serde_json::{json, Value}; use std::f64::consts::{PI, TAU}; /// What the modes remember between clicks (xfig's `total_len`, /// `total_area` and `signed_area`). #[derive(Debug, Clone, Default)] pub(crate) struct MeasureState { /// The running total of lengths, Fig units. pub total_len: f64, /// The running total of areas, square Fig units. pub total_area: f64, /// Whether areas keep their sign (set by the area mode's Shift+right /// button, "reset to +-0"). pub signed_area: bool, } pub(super) fn commands() -> Vec { vec![ Command { id: "object.measure_angle", label: "Measure angle", menu: "", shortcut: "", params: "{\"points\": [[x, y], [x, y], [x, y]]} the angle at the second point between the lines to the first and the third, or {\"id\": line, polygon or arc, \"x\", \"y\": the corner clicked (a vertex within \"tolerance\", default the pick tolerance; not needed for an arc: its angle at the centre), \"tolerance\"?}; \"save\"?: bool also stores it as the indicator's rotation angle (xfig's middle button). Shows \"Angle defined by three points: 45.00 Degrees\" (or at a polyline corner, at the arc centre) in the message panel; nothing is changed. Returns {\"degrees\", \"kind\", \"text\"}", mutates: false, enabled: super::always, run: measure_angle, }, Command { id: "object.measure_length", label: "Measure length", menu: "", shortcut: "", params: "{\"id\": line, polygon, box, arc box, arc, circle or ellipse, \"add\"?: bool (add to the running total, xfig's middle button)} — shows \"Length of polygon is 3.0 in, accumulated 5.0 in\" in the drawing's units; nothing is changed. Returns {\"kind\", \"length\" (Fig units), \"accumulated\"? (Fig units), \"text\"}", mutates: false, enabled: super::always, run: measure_length, }, Command { id: "object.measure_area", label: "Measure area", menu: "", shortcut: "", params: "{\"id\": polygon, box, arc box, arc, circle or ellipse, \"add\"?: bool (add to the running total, xfig's middle button)} — shows \"Area of box is 2.0 square in, accumulated 4.0 square in\"; the area is absolute unless the total was reset with \"signed\"; nothing is changed. Returns {\"kind\", \"area\" (square Fig units), \"accumulated\"?, \"signed\", \"text\"}", mutates: false, enabled: super::always, run: measure_area, }, Command { id: "object.measure_reset", label: "Reset measured total", menu: "", shortcut: "", params: "{\"what\": \"length\"|\"area\", \"signed\"?: bool (area only: from now on areas keep their sign, xfig's Shift+right button \"reset to +-0\")} — sets the running total to 0", mutates: false, enabled: super::always, run: measure_reset, }, ] } // ---- geometry (u_geom.c) ------------------------------------------------- fn pt_ok(p: Point) -> bool { p.x.is_finite() && p.y.is_finite() } /// `compute_3p_angle`: the angle at `p2` from the line to `p1` to the line /// to `p3`, in [0, 2 pi); `None` when a line has no length. pub(crate) fn angle_3p(p1: Point, p2: Point, p3: Point) -> Option { let (vx, vy) = (p1.x - p2.x, p1.y - p2.y); let (wx, wy) = (p3.x - p2.x, p3.y - p2.y); if (vx == 0.0 && vy == 0.0) || (wx == 0.0 && wy == 0.0) { return None; } let mut dang = -wy.atan2(wx) + vy.atan2(vx); if dang < 0.0 { dang += TAU; } dang.is_finite().then_some(dang) } /// The vertices of a line as xfig stores them: a closed one repeats its /// first point at the end. fn xfig_points(l: &Polyline) -> Vec { let mut pts: Vec = l.points.clone(); if l.kind != PolyKind::Polyline && pts.len() > 1 && pts.first() != pts.last() { if let Some(f) = pts.first().copied() { pts.push(f); } } pts } /// `compute_line_angle`: the angle at the vertex `p` between the sides /// meeting there (in [0, 2 pi)). The first point of an open line has no /// side before it, the last none after it; a polygon's closing point is its /// first. pub(crate) fn line_angle(l: &Polyline, p: Point) -> Option { let pts = xfig_points(l); for (i, q) in pts.iter().enumerate() { if q.x != p.x || q.y != p.y || i == 0 { continue; } let Some(vor) = pts.get(i - 1) else { continue }; let nach = match pts.get(i + 1) { Some(n) => Some(n), None if l.kind == PolyKind::Polygon => pts.get(1), None => None, }; if let Some(a) = nach.and_then(|n| angle_3p(*vor, *q, *n)) { return Some(a); } } None } /// `compute_arc_angle`: the oriented angle swept by the arc, in (-2 pi, 2 pi). pub(crate) fn arc_angle(a: &Arc) -> Option { let [p0, p1, p2] = a.points; let (u, v, w) = (p0 - a.center, p1 - a.center, p2 - a.center); if (u.x == 0.0 && u.y == 0.0) || (v.x == 0.0 && v.y == 0.0) || (w.x == 0.0 && w.y == 0.0) { return None; } let (uang, vang, wang) = (u.y.atan2(u.x), v.y.atan2(v.x), w.y.atan2(w.x)); let dang = if uang >= wang { if uang >= vang && vang >= wang { uang - wang } else { -TAU + uang - wang } } else if uang <= vang && vang <= wang { uang - wang } else { TAU + uang - wang }; dang.is_finite().then_some(dang) } fn arc_radius(a: &Arc) -> f64 { let u = a.points[0] - a.center; if u.x == 0.0 && u.y == 0.0 { 0.0 } else { u.x.hypot(u.y) } } /// `compute_poly_length`: the sides added up (a closed line closes). fn poly_length(l: &Polyline) -> f64 { xfig_points(l) .windows(2) .map(|w| match (w.first(), w.get(1)) { (Some(a), Some(b)) => (b.x - a.x).hypot(b.y - a.y), _ => 0.0, }) .sum() } /// `compute_arc_length`. fn arc_length(a: &Arc) -> Option { arc_angle(a).map(|alpha| arc_radius(a) * alpha.abs()) } /// `compute_poly_area`: the signed area by the shoelace formula (negative /// where the points turn the other way). fn poly_area(l: &Polyline) -> f64 { xfig_points(l) .windows(2) .map(|w| match (w.first(), w.get(1)) { (Some(p), Some(q)) => -p.x * q.y + p.y * q.x, _ => 0.0, }) .sum::() / 2.0 } /// `compute_arc_area`: the sector, less the triangle for an open arc. fn arc_area(a: &Arc) -> Option { let alpha = arc_angle(a)?; let r = arc_radius(a); let mut area = 0.5 * alpha * r * r; if !a.pie { let tri = 0.5 * r * r * alpha.sin().abs(); if area > 0.0 { area -= tri; } else { area += tri; } } Some(area) } /// The length of an ellipse's outline: 2 pi r for a circle, else the /// approximation from The Math Forum that xfig uses. fn ellipse_length(e: &Ellipse) -> (&'static str, f64) { let (a, b) = (e.radii.x, e.radii.y); if a == b { return ("circle", PI * a * 2.0); } let z = (a - b) / (a + b); ( "ellipse", PI * (a + b) * (1.0 + 3.0 * z * z / (10.0 + (4.0 - 3.0 * z * z).sqrt())), ) } /// An arc box's corner radius in Fig units. fn corner_radius(l: &Polyline) -> f64 { l.radius.max(0) as f64 * FIG_PER_STYLE_UNIT } /// What kind of thing a length or an area was measured of, and the value /// in Fig units (`init_lenmeas_object`). pub(crate) fn length_of(o: &Object) -> Option<(&'static str, f64)> { match &o.kind { ObjectKind::Polyline(l) => { let len = poly_length(l); Some(match l.kind { PolyKind::Box | PolyKind::Picture => ("box", len), // xfig takes off one circle's circumference for the corners. PolyKind::ArcBox => ("arcbox", len - PI * corner_radius(l) * 2.0), PolyKind::Polygon => ("polygon", len), PolyKind::Polyline => ("polyline", len), }) } ObjectKind::Arc(a) => arc_length(a).map(|l| ("arc", l)), ObjectKind::Ellipse(e) => Some(ellipse_length(e)), _ => None, } } /// The signed area (`init_areameas_object`). pub(crate) fn area_of(o: &Object) -> Option<(&'static str, f64)> { match &o.kind { ObjectKind::Polyline(l) if l.kind != PolyKind::Polyline => { let area = poly_area(l); Some(match l.kind { PolyKind::Box => ("box", area), PolyKind::ArcBox => { let r = corner_radius(l); // xfig subtracts the corners from the signed area, which // adds them for a box whose points turn the other way; // here they always come off the magnitude. ("arcbox", area - area.signum() * PI * r * r) } _ => ("polygon", area), }) } ObjectKind::Arc(a) => arc_area(a).map(|v| (if a.pie { "sector" } else { "open arc" }, v)), ObjectKind::Ellipse(e) => Some(("ellipse", PI * e.radii.x * e.radii.y)), _ => None, } } // ---- the message panel (w_msgpanel.c) ------------------------------------ /// Decimals after the point of a length (xfig's `cur_dimline_prec`). const DIMLINE_PREC: usize = 1; /// A length (or, with `square`, an area) in the drawing's units, scaled by /// the unit/scale setting, as xfig's `make_dimension_string` writes it: /// "3.5 cm", "1-3/4 in", "2.000 square cm". pub(crate) fn dimension_string(e: &Engine, value: f64, square: bool) -> String { let scale = e.unit_scale.factor; let unit = if e.unit_scale.unit.is_empty() { e.doc().unit_name() } else { e.unit_scale.unit.as_str() }; let prec = DIMLINE_PREC; match e.doc().settings.units { Units::Metric => { if square { format!("{:.3} square {unit}", value / (FIG_PER_CM * FIG_PER_CM) * scale * scale) } else { format!("{:.prec$} {unit}", value / FIG_PER_CM * scale) } } Units::Inches if square => format!( "{:.prec$} square {unit}", value / (FIG_PER_INCH * FIG_PER_INCH) * scale * scale ), Units::Inches => length_string(value, Units::Inches, &e.unit_scale, e.doc().unit_name(), prec as u32), } } /// `lenmeas_msg`. pub(crate) fn length_message(e: &Engine, kind: &str, len: f64, total: Option) -> String { let l = dimension_string(e, len, false); match total { Some(t) => format!("Length of {kind} is {l}, accumulated {}", dimension_string(e, t, false)), None => format!("Length of {kind} is {l}"), } } /// `areameas_msg`. pub(crate) fn area_message(e: &Engine, kind: &str, area: f64, total: Option) -> String { let a = dimension_string(e, area, true); match total { Some(t) => format!("Area of {kind} is {a}, accumulated {}", dimension_string(e, t, true)), None => format!("Area of {kind} is {a}"), } } /// `angle_msg`: " Degrees". fn angle_message(what: &str, radians: f64) -> String { format!("{what}{:.2} Degrees", radians * 180.0 / PI) } // ---- the commands ---------------------------------------------------------- fn target(e: &Engine, p: &Value) -> Result { let id = super::chop::id_param(p, "id")?; super::chop::object(e, id) } /// Fail with xfig's message in the message panel too. fn refuse(e: &mut Engine, msg: &str) -> Result { e.set_status(msg); Err(EngineError::bad_params(msg)) } fn measure_length(e: &mut Engine, p: &Value) -> CmdResult { let o = target(e, p)?; let add = opt_bool(p, "add")?.unwrap_or(false); let Some((kind, len)) = length_of(&o).filter(|(_, l)| l.is_finite()) else { return refuse(e, "Sorry, can't measure length of this object"); }; let total = if add { e.measure.total_len += len; Some(e.measure.total_len) } else { None }; let text = length_message(e, kind, len, total); e.set_status(text.clone()); Ok(json!({"kind": kind, "length": len, "accumulated": total, "text": text})) } fn measure_area(e: &mut Engine, p: &Value) -> CmdResult { let o = target(e, p)?; let add = opt_bool(p, "add")?.unwrap_or(false); let Some((kind, mut area)) = area_of(&o).filter(|(_, a)| a.is_finite()) else { return refuse(e, "Sorry, can't measure area of this object"); }; let signed = e.measure.signed_area; if !signed { area = area.abs(); } let total = if add { e.measure.total_area += area; Some(e.measure.total_area) } else { None }; let text = area_message(e, kind, area, total); e.set_status(text.clone()); Ok(json!({"kind": kind, "area": area, "accumulated": total, "signed": signed, "text": text})) } fn measure_reset(e: &mut Engine, p: &Value) -> CmdResult { let what = super::string(p, "what")?; match what.as_str() { "length" => { e.measure.total_len = 0.0; e.set_status("length reset to 0"); } "area" => { let signed = opt_bool(p, "signed")?.unwrap_or(false); e.measure.total_area = 0.0; e.measure.signed_area = signed; e.set_status(if signed { "signed area reset to 0" } else { "area reset to 0" }); } _ => return Err(EngineError::bad_params("\"what\" must be \"length\" or \"area\"")), } Ok( json!({"what": what, "length": e.measure.total_len, "area": e.measure.total_area, "signed": e.measure.signed_area}), ) } /// `angle_save` (`set_and_show_rotnangle`): the angle, 0 to 180 degrees, /// becomes the rotation angle of the indicator. fn save_angle(e: &mut Engine, radians: f64) { let mut degr = (radians * 180.0 / PI).abs(); if degr > 180.0 { degr = 360.0 - degr; } if degr.is_finite() { e.indicator.rotate_angle = degr; } } /// The vertex of a line within `tol` of (`x`, `y`) that xfig's point search /// finds first (`next_line_point_found`). pub(crate) fn vertex_near(l: &Polyline, x: f64, y: f64, tol: f64) -> Option { l.points .iter() .copied() .find(|q| pt_ok(*q) && (q.x - x).abs() <= tol && (q.y - y).abs() <= tol) } fn measure_angle(e: &mut Engine, p: &Value) -> CmdResult { let save = opt_bool(p, "save")?.unwrap_or(false); let (what, kind, ang) = if p.get("points").is_some() { let pts = super::points(p, "points")?; let [a, b, c] = pts.as_slice() else { return Err(EngineError::bad_params("\"points\" must be three points")); }; let Some(v) = angle_3p(*a, *b, *c) else { return refuse(e, "Can't compute angle"); }; // `anglemeas_third`: the angle is shown between -180 and 180. let v = if v > PI { v - TAU } else { v }; ("Angle defined by three points: ", "three points", v) } else { let o = target(e, p)?; match &o.kind { ObjectKind::Polyline(l) if matches!(l.kind, PolyKind::Polyline | PolyKind::Polygon) => { let (x, y) = (super::num(p, "x")?, super::num(p, "y")?); let tol = match opt_num(p, "tolerance")? { Some(t) => t.clamp(0.0, 1.0e6), None => e.pick_tolerance(), }; let Some(q) = vertex_near(l, x, y, tol) else { return Err(EngineError::bad_params("no corner of the line at that point")); }; let Some(v) = line_angle(l, q) else { return refuse(e, "Can't compute angle at endpoint"); }; let v = if v > PI { v - TAU } else { v }; ("Angle at polyline corner: ", "corner", v) } ObjectKind::Arc(a) => { let Some(v) = arc_angle(a) else { return refuse(e, "Can't compute angle"); }; ("Angle at arc center: ", "arc", v) } _ => return refuse(e, "Sorry, can't measure the angle of this object"), } }; if save { save_angle(e, ang); } let text = angle_message(what, ang); e.set_status(text.clone()); Ok(json!({"degrees": ang * 180.0 / PI, "kind": kind, "text": text, "saved": save})) } /// The object a click at (`x`, `y`) picks in the angle mode's point /// search: the first line or polygon with a vertex there, then the first /// arc with one of its three points there (`do_point_search`: xfig's /// ellipses and splines are not in this mode's mask). pub(crate) fn angle_target(e: &Engine, x: f64, y: f64, tol: f64) -> Option { let doc = e.doc(); let first_line = doc.objects.iter().find(|o| { matches!(&o.kind, ObjectKind::Polyline(l) if matches!(l.kind, PolyKind::Polyline | PolyKind::Polygon) && vertex_near(l, x, y, tol).is_some()) && e.is_visible(o) }); if let Some(o) = first_line { return Some(o.id); } doc.objects .iter() .find(|o| { matches!(&o.kind, ObjectKind::Arc(a) if a.points.iter().any(|q| pt_ok(*q) && (q.x - x).abs() <= tol && (q.y - y).abs() <= tol)) && e.is_visible(o) }) .map(|o| o.id) } .