Skip to main content

dotloom_geometry/
dimension.rs

1//! Dimension (measurement annotation) geometry.
2//!
3//! These functions compute the drawable pieces of a dimension from the measured
4//! points. Values come from the exact `f64` model, never from flattened render data.
5
6use core::f64::consts::{PI, TAU};
7
8use serde::{Deserialize, Serialize};
9
10use crate::{Arc, GeoResult, GeometryError, Point, Segment, Vector, normalize_angle};
11
12/// Orientation of a linear dimension.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub enum LinearKind {
16    /// Parallel to the measured points.
17    #[default]
18    Aligned,
19    /// Horizontal projection.
20    Horizontal,
21    /// Vertical projection.
22    Vertical,
23}
24
25/// Visual parameters in model units.
26#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
27pub struct DimensionStyle {
28    /// Gap between the measured point and the extension line start.
29    pub extension_gap: f64,
30    /// Extension line overshoot beyond the dimension line.
31    pub extension_overshoot: f64,
32    /// Arrow length.
33    pub arrow_size: f64,
34    /// Text height.
35    pub text_height: f64,
36}
37
38impl Default for DimensionStyle {
39    fn default() -> Self {
40        Self { extension_gap: 1.0, extension_overshoot: 2.0, arrow_size: 3.0, text_height: 3.5 }
41    }
42}
43
44/// An arrow head: tip and unit direction pointing at the tip.
45#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
46pub struct Arrow {
47    /// Tip position.
48    pub tip: Point,
49    /// Unit direction of travel towards the tip.
50    pub direction: Vector,
51}
52
53/// Drawable dimension pieces.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct DimensionGeometry {
56    /// Extension and dimension lines.
57    pub lines: Vec<Segment>,
58    /// Dimension arc (angular dimensions).
59    pub arc: Option<Arc>,
60    /// Arrow heads.
61    pub arrows: Vec<Arrow>,
62    /// Text anchor (center of the label).
63    pub text_position: Point,
64    /// Text rotation (kept readable: within `(-π/2, π/2]`).
65    pub text_rotation: f64,
66    /// Measured value: length in model units, or angle in radians.
67    pub value: f64,
68}
69
70fn readable(angle: f64) -> f64 {
71    let a = crate::normalize_angle_signed(angle);
72    if a > PI / 2.0 + 1e-12 {
73        a - PI
74    } else if a <= -PI / 2.0 + 1e-12 {
75        a + PI
76    } else {
77        a
78    }
79}
80
81/// Linear dimension between `p1` and `p2`; the dimension line passes through `through`.
82pub fn linear(
83    p1: Point,
84    p2: Point,
85    through: Point,
86    kind: LinearKind,
87    style: DimensionStyle,
88) -> GeoResult<DimensionGeometry> {
89    if !(p1.is_finite() && p2.is_finite() && through.is_finite()) {
90        return Err(GeometryError::NonFinite("dimension points"));
91    }
92    let u = match kind {
93        LinearKind::Aligned => (p2 - p1).normalize().ok_or(GeometryError::Degenerate("dimension points coincide"))?,
94        LinearKind::Horizontal => Vector::new(1.0, 0.0),
95        LinearKind::Vertical => Vector::new(0.0, 1.0),
96    };
97    let n = u.perp();
98    let d1 = (through - p1).dot(n);
99    let d2 = (through - p2).dot(n);
100    let q1 = p1 + n * d1;
101    let q2 = p2 + n * d2;
102    let value = (p2 - p1).dot(u).abs();
103    if value == 0.0 {
104        return Err(GeometryError::Degenerate("projected dimension length is zero"));
105    }
106    let ext = |p: Point, q: Point| -> Option<Segment> {
107        let v = q - p;
108        let len = v.length();
109        let dir = v.normalize()?;
110        (len > style.extension_gap)
111            .then(|| Segment::new(p + dir * style.extension_gap, q + dir * style.extension_overshoot))
112    };
113    let mut lines: Vec<Segment> = [ext(p1, q1), ext(p2, q2)].into_iter().flatten().collect();
114    lines.push(Segment::new(q1, q2));
115    let along = (q2 - q1).normalize().unwrap_or(u);
116    Ok(DimensionGeometry {
117        lines,
118        arc: None,
119        arrows: vec![Arrow { tip: q1, direction: -along }, Arrow { tip: q2, direction: along }],
120        // Label sits on the side of the dimension line away from the measured points.
121        text_position: q1.midpoint(q2) + n * (style.text_height * 0.75 * if d1 < 0.0 { -1.0 } else { 1.0 }),
122        text_rotation: readable(u.angle()),
123        value,
124    })
125}
126
127/// Angular dimension at `vertex` between rays through `a` and `b` (CCW from `a`),
128/// with the dimension arc at distance `radius`.
129pub fn angular(vertex: Point, a: Point, b: Point, radius: f64, style: DimensionStyle) -> GeoResult<DimensionGeometry> {
130    let va = (a - vertex).normalize().ok_or(GeometryError::Degenerate("angular ray a"))?;
131    let vb = (b - vertex).normalize().ok_or(GeometryError::Degenerate("angular ray b"))?;
132    if !(radius.is_finite() && radius > 0.0) {
133        return Err(GeometryError::InvalidArgument("angular dimension radius must be > 0"));
134    }
135    let start = va.angle();
136    let sweep = normalize_angle(vb.angle() - start);
137    if sweep == 0.0 {
138        return Err(GeometryError::Degenerate("angular rays coincide"));
139    }
140    let arc = Arc::new(vertex, radius, start, sweep)?;
141    let mid_dir = Vector::from_angle(start + sweep * 0.5);
142    Ok(DimensionGeometry {
143        lines: vec![
144            Segment::new(
145                vertex + va * style.extension_gap.min(radius * 0.5),
146                vertex + va * (radius + style.extension_overshoot),
147            ),
148            Segment::new(
149                vertex + vb * style.extension_gap.min(radius * 0.5),
150                vertex + vb * (radius + style.extension_overshoot),
151            ),
152        ],
153        arc: Some(arc),
154        arrows: vec![
155            Arrow { tip: arc.start_point(), direction: -arc.tangent_at(0.0) },
156            Arrow { tip: arc.end_point(), direction: arc.tangent_at(1.0) },
157        ],
158        text_position: vertex + mid_dir * (radius + style.text_height),
159        text_rotation: readable(mid_dir.angle() - PI / 2.0),
160        value: sweep.min(TAU),
161    })
162}
163
164/// Radial dimension from `center` to the circle at angle `angle`.
165pub fn radial(center: Point, radius: f64, angle: f64, style: DimensionStyle) -> GeoResult<DimensionGeometry> {
166    if !(radius.is_finite() && radius > 0.0 && center.is_finite() && angle.is_finite()) {
167        return Err(GeometryError::InvalidArgument("radial dimension input"));
168    }
169    let dir = Vector::from_angle(angle);
170    let tip = center + dir * radius;
171    Ok(DimensionGeometry {
172        lines: vec![Segment::new(center, tip)],
173        arc: None,
174        arrows: vec![Arrow { tip, direction: dir }],
175        text_position: center + dir * (radius * 0.5) + dir.perp() * (style.text_height * 0.75),
176        text_rotation: readable(angle),
177        value: radius,
178    })
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn aligned_dimension_value_and_offset() {
187        let d = linear(
188            Point::new(0.0, 0.0),
189            Point::new(30.0, 40.0),
190            Point::new(-8.0, 6.0),
191            LinearKind::Aligned,
192            DimensionStyle::default(),
193        )
194        .unwrap();
195        assert!((d.value - 50.0).abs() < 1e-12);
196        let dim_line = d.lines.last().unwrap();
197        // Dimension line is parallel to the measured points and offset by 10.
198        assert!((dim_line.length() - 50.0).abs() < 1e-12);
199        assert!((dim_line.a.distance(Point::ORIGIN) - 10.0).abs() < 1e-12);
200    }
201
202    #[test]
203    fn horizontal_projection() {
204        let d = linear(
205            Point::new(0.0, 0.0),
206            Point::new(30.0, 40.0),
207            Point::new(0.0, 60.0),
208            LinearKind::Horizontal,
209            DimensionStyle::default(),
210        )
211        .unwrap();
212        assert!((d.value - 30.0).abs() < 1e-12);
213        assert!((d.text_rotation).abs() < 1e-12);
214    }
215
216    #[test]
217    fn text_stays_readable() {
218        let d = linear(
219            Point::new(10.0, 0.0),
220            Point::new(0.0, 0.0),
221            Point::new(5.0, 5.0),
222            LinearKind::Aligned,
223            DimensionStyle::default(),
224        )
225        .unwrap();
226        assert!(d.text_rotation.abs() < 1e-12, "{}", d.text_rotation);
227    }
228
229    #[test]
230    fn angular_value() {
231        let d =
232            angular(Point::ORIGIN, Point::new(1.0, 0.0), Point::new(0.0, 2.0), 5.0, DimensionStyle::default()).unwrap();
233        assert!((d.value - PI / 2.0).abs() < 1e-12);
234        assert!(d.arc.is_some());
235    }
236}