//! Dimension lines: a compound of the measured line, a box with its length //! and (optionally) a tick at each end, recomputed whenever the line //! changes. //! // Ported from xfig src/d_line.c, src/u_create.c (`create_dimension_line`), src/e_scale.c // (`rescale_dimension_line`), src/f_util.c (`dimline_components`) and src/w_msgpanel.c // (`make_dimension_string`) (xfig licence, see LICENSE-xfig). use crate::view::UnitScale; use rustfig_geom::Point; use rustfig_model::{ Arrow, CapStyle, ColorRef, Compound, JoinStyle, Justify, LineKind, Object, ObjectKind, PolyKind, Polyline, Style, Text, Units, }; use serde::{Deserialize, Serialize}; /// The comment that marks the compound (`dimline_components`). const COMPOUND_PREFIX: &str = "Dimension line:"; const MAIN: &str = "main dimension line"; const BOX: &str = "text box"; const TICK: &str = "tick"; const FIXED: &str = "fixed text"; /// The dimension-line settings of the indicator (xfig's `cur_dimline_*`, /// `mode.c`; its "Dimension line settings" panel). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct DimlineSettings { /// Thickness of the main line and the ticks' default. pub thick: i32, pub style: LineKind, pub color: i32, /// Arrow at the start and the end: xfig's type*2 - 1 + style, or -1 for none (2: filled triangle). pub left_arrow: i32, pub right_arrow: i32, /// Arrow length and width as multiples of the line thickness. pub arrow_length: f64, pub arrow_width: f64, pub ticks: bool, pub tick_thick: i32, pub box_thick: i32, pub box_color: i32, pub text_color: i32, pub font: i32, pub font_size: f64, pub postscript: bool, /// The text is typed instead of showing the length. pub fixed: bool, /// Decimals of the length. pub precision: u32, } impl Default for DimlineSettings { fn default() -> Self { DimlineSettings { thick: 1, style: LineKind::Solid, color: 0, left_arrow: 2, right_arrow: 2, arrow_length: 8.0, arrow_width: 4.0, ticks: true, tick_thick: 1, box_thick: 1, box_color: 7, text_color: 0, font: 0, font_size: 12.0, postscript: true, fixed: false, precision: 1, } } } impl DimlineSettings { /// Keep the values in range (hostile `indicator.set` input). pub fn sanitize(&mut self) { self.thick = self.thick.clamp(0, 10_000); self.tick_thick = self.tick_thick.clamp(0, 10_000); self.box_thick = self.box_thick.clamp(0, 10_000); self.left_arrow = self.left_arrow.clamp(-1, 40); self.right_arrow = self.right_arrow.clamp(-1, 40); self.precision = self.precision.min(9); for v in [&mut self.arrow_length, &mut self.arrow_width] { if !v.is_finite() { *v = 4.0; } *v = v.clamp(0.0, 1000.0); } if !self.font_size.is_finite() { self.font_size = 12.0; } self.font_size = self.font_size.clamp(1.0, 2000.0); self.color = self.color.clamp(-1, 543); self.box_color = self.box_color.clamp(-1, 543); self.text_color = self.text_color.clamp(-1, 543); } /// `backward_dim_arrow` / `forward_dim_arrow`: the arrow of an end. fn arrow(&self, v: i32) -> Option { if v < 0 { return None; } let kind = (v + 1) / 2; let t = f64::from(self.thick.max(1)); Some(Arrow { kind, style: v - (kind * 2 - 1), thickness: t, // 60 x 120 Fig units for a thickness of 1, like xfig's default arrow. width: (self.arrow_width * 15.0 * t).max(1.0), height: (self.arrow_length * 15.0 * t).max(1.0), }) } } /// What a new dimension line takes from the rest of the indicator. pub struct DimlineDefaults { pub pen_color: i32, pub depth: i32, pub join: JoinStyle, pub style_val: f64, pub dot_gap: f64, } /// Whether the object is a dimension line (its compound comment). pub fn is_dimline(o: &Object) -> bool { matches!(o.kind, ObjectKind::Compound(_)) && o.comments.first().is_some_and(|c| c.starts_with(COMPOUND_PREFIX)) } /// xfig's `make_dimension_string`: the length in the figure units and scale, /// with `precision` decimals, or fractions of an inch ([`crate::view::length_string`]). pub fn length_text(length: f64, units: Units, scale: &UnitScale, ruler: &str, precision: u32) -> String { crate::view::length_string(length, units, scale, ruler, precision) } fn line_style(kind: LineKind, thickness: i32, d: &DimlineDefaults) -> (LineKind, f64) { let t = f64::from(thickness); match kind { LineKind::Solid | LineKind::Default => (kind, 0.0), LineKind::Dotted => (kind, d.dot_gap * (t + 1.0) / 2.0), k => (k, d.style_val * (t + 1.0) / 2.0), } } /// `create_dimension_line`: the compound for the line from `a` to `b`. The /// shapes of the ticks and box are filled in by [`recompute`]. pub fn create( s: &DimlineSettings, d: &DimlineDefaults, a: Point, b: Point, text: Option<&str>, fmt: &dyn Fn(f64) -> String, ) -> Object { // Two objects are needed on top of the line. let depth = d.depth.max(2); let (kind, style_val) = line_style(s.style, s.thick, d); let main_style = Style { line: kind, thickness: s.thick, pen_color: ColorRef(s.color), fill_color: ColorRef::WHITE, depth, pen_style: -1, area_fill: -1, style_val, cap: CapStyle::Butt, join: d.join, }; let mut line = Polyline::new(PolyKind::Polyline, main_style, vec![a, b]); line.backward_arrow = s.arrow(s.left_arrow); line.forward_arrow = s.arrow(s.right_arrow); let mut main = Object::new(ObjectKind::Polyline(line)); main.comments = vec![MAIN.into()]; let box_style = Style { line: LineKind::Solid, thickness: s.box_thick, pen_color: ColorRef(d.pen_color), fill_color: ColorRef(s.box_color), depth: depth - 1, pen_style: -1, // Full saturation of the fill colour. area_fill: 20, style_val: 0.0, cap: CapStyle::Butt, join: d.join, }; let mut boxed = Object::new(ObjectKind::Polyline(Polyline::new( PolyKind::Polygon, box_style, vec![Point::ZERO; 5], ))); boxed.comments = vec![BOX.into()]; let mut members = vec![main, boxed]; if s.ticks { for _ in 0..2 { let tick_style = Style { line: LineKind::Solid, thickness: s.tick_thick, pen_color: ColorRef(s.color), fill_color: ColorRef::WHITE, depth, pen_style: -1, area_fill: -1, style_val: 0.0, cap: CapStyle::Butt, join: d.join, }; let mut t = Object::new(ObjectKind::Polyline(Polyline::new( PolyKind::Polyline, tick_style, vec![Point::ZERO; 2], ))); t.comments = vec![TICK.into()]; members.push(t); } } let mut label = Object::new(ObjectKind::Text(Text { justify: Justify::Center, color: ColorRef(s.text_color), depth: depth - 2, pen_style: -1, font: s.font, size: s.font_size, angle: 0.0, flags: if s.postscript { rustfig_model::text_flags::POSTSCRIPT } else { 0 }, height: 0.0, length: 0.0, origin: Point::ZERO, text: String::new(), local_font: None, })); if s.fixed { label.comments = vec![FIXED.into()]; if let ObjectKind::Text(t) = &mut label.kind { t.text = text.unwrap_or_default().to_string(); } } members.push(label); let mut comp = Object::new(ObjectKind::Compound(Compound { objects: members })); comp.comments = vec![if s.fixed { format!("{COMPOUND_PREFIX} User-defined text") } else { COMPOUND_PREFIX.to_string() }]; recompute(&mut comp, s, fmt); comp } /// A text whose comment starts with "fixed text" keeps its string. fn is_fixed(m: &Object) -> bool { m.comments .first() .and_then(|x| x.get(..FIXED.len())) .is_some_and(|x| x.eq_ignore_ascii_case(FIXED)) } fn round(v: f64) -> f64 { v.round() } /// `rescale_dimension_line` with no scaling: recompute the text and its /// angle, the box and the ticks from the main line. Returns false when the /// object is not a dimension line or its main line was deleted. pub fn recompute(o: &mut Object, s: &DimlineSettings, fmt: &dyn Fn(f64) -> String) -> bool { if !is_dimline(o) { return false; } let ObjectKind::Compound(c) = &mut o.kind else { return false; }; let main_pts = c.objects.iter().find_map(|m| match &m.kind { ObjectKind::Polyline(p) if m.comments.first().map(String::as_str) == Some(MAIN) => Some(p.points.clone()), _ => None, }); let Some((mut p1, mut p2)) = main_pts.as_deref().and_then(|p| Some((*p.first()?, *p.get(1)?))) else { return false; }; // Drawn right to left, or top to bottom at 90 degrees: swap the two ends. if p1.x > p2.x || (p1.y < p2.y && p1.x == p2.x) { std::mem::swap(&mut p1, &mut p2); } let (dx, dy) = (p2.x - p1.x, p2.y - p1.y); let length = dx.hypot(dy); if !length.is_finite() || length < 1e-9 { return false; } let (cx, cy) = (round((p1.x + p2.x) / 2.0), round((p1.y + p2.y) / 2.0)); let mut angle = -dy.atan2(dx); while angle < 0.0 { angle += std::f64::consts::TAU; } // The text: the length unless the text is fixed. let text_idx = c.objects.iter().position(|m| matches!(m.kind, ObjectKind::Text(_))); let (ascent, descent, text_len) = { let idx = match text_idx { Some(i) => i, None => { // The user deleted it: make a new one. let mut t = Object::new(ObjectKind::Text(Text { justify: Justify::Center, color: ColorRef(s.text_color), depth: 0, pen_style: -1, font: s.font, size: s.font_size, angle: 0.0, flags: if s.postscript { rustfig_model::text_flags::POSTSCRIPT } else { 0 }, height: 0.0, length: 0.0, origin: Point::ZERO, text: String::new(), local_font: None, })); t.comments = Vec::new(); c.objects.push(t); c.objects.len() - 1 } }; let fixed = c.objects.get(idx).is_some_and(is_fixed); let Some(Object { kind: ObjectKind::Text(t), .. }) = c.objects.get_mut(idx) else { return false; }; if !fixed { t.text = fmt(length); } let m = crate::measure_in_font(t, &t.text); t.length = m.width; t.height = m.ascent; t.angle = angle; t.justify = Justify::Center; t.origin = Point::new(round(cx - dy / length * m.descent), round(cy + dx / length * m.descent)); (m.ascent, m.descent, m.width) }; let fixed_text = c .objects .iter() .any(|m| matches!(m.kind, ObjectKind::Text(_)) && is_fixed(m)); if !fixed_text { o.comments = vec![format!("{COMPOUND_PREFIX} {}", fmt(length))]; } let ObjectKind::Compound(c) = &mut o.kind else { return false; }; // Half the text length plus a margin, half the height plus a margin. let tlen2 = text_len / 2.0 + 60.0; let tht2 = (ascent + descent) / 2.0 + 15.0; let (ux, uy) = (dx / length, dy / length); // A point at `a` along the line and `b` across it, from `origin`. let at = |origin: Point, a: f64, b: f64| { Point::new(round(origin.x + a * ux - b * uy), round(origin.y + a * uy + b * ux)) }; let centre = Point::new(cx, cy); let mut ticks_done = 0; for m in &mut c.objects { let tag = m.comments.first().map(String::as_str); let ObjectKind::Polyline(pl) = &mut m.kind else { continue; }; match tag { Some(BOX) => { pl.points = vec![ at(centre, -tlen2, -tht2), at(centre, -tlen2, tht2), at(centre, tlen2, tht2), at(centre, tlen2, -tht2), at(centre, -tlen2, -tht2), ]; } Some(TICK) => { let end = if ticks_done == 0 { p1 } else { p2 }; ticks_done += 1; pl.points = vec![at(end, 0.0, -tht2), at(end, 0.0, tht2)]; } _ => {} } } true } #[cfg(test)] mod tests { use super::*; fn fmt(l: f64) -> String { length_text(l, Units::Inches, &UnitScale::default(), "in", 1) } fn parts(o: &Object) -> Vec<(String, Vec)> { let ObjectKind::Compound(c) = &o.kind else { return Vec::new(); }; c.objects .iter() .filter_map(|m| match &m.kind { ObjectKind::Polyline(p) => Some((m.comments.first().cloned().unwrap_or_default(), p.points.clone())), _ => None, }) .collect() } #[test] fn horizontal_dimension_line() { let d = DimlineDefaults { pen_color: 0, depth: 50, join: JoinStyle::Miter, style_val: 4.0, dot_gap: 3.0, }; let o = create( &DimlineSettings::default(), &d, Point::new(0.0, 0.0), Point::new(2400.0, 0.0), None, &fmt, ); assert!(is_dimline(&o)); assert_eq!(o.comments, vec!["Dimension line: 2 in".to_string()]); let p = parts(&o); assert_eq!(p.len(), 4, "line, box and two ticks, besides the text"); let ticks: Vec<_> = p.iter().filter(|(t, _)| t == "tick").collect(); assert_eq!(ticks.len(), 2); // The ticks stand upright at the ends. assert_eq!(ticks[0].1[0].x, 0.0); assert_eq!(ticks[0].1[1].x, 0.0); assert_eq!(ticks[1].1[0].x, 2400.0); } #[test] fn dimension_line_label_uses_fractions() { let d = DimlineDefaults { pen_color: 0, depth: 50, join: JoinStyle::Miter, style_val: 4.0, dot_gap: 3.0, }; let o = create( &DimlineSettings::default(), &d, Point::new(0.0, 0.0), Point::new(1800.0, 0.0), None, &fmt, ); assert_eq!(o.comments, vec!["Dimension line: 1-1/2 in".to_string()]); } #[test] fn length_text_uses_unit_scale() { let sc = UnitScale { factor: 2.0, unit: "m".into(), ..UnitScale::default() }; assert_eq!(length_text(450.0, Units::Metric, &sc, "cm", 2), "2.00 m"); } } .