Skip to main content

geop_core_sketch/
geometry.rs

1//! The geometry of sketch entities, generic over [`Number`] so every formula
2//! serves both the residuals (differentiated with [`geop_core_math::dual::Dual`])
3//! and plain evaluation (profiles, rendering).
4//!
5//! An arc is stored as `(start, end, sweep)`. Its signed curvature follows
6//! from those as `k = 2 sin(sweep / 2) / |end - start|`, so `(start, end,
7//! sweep)` and `(start, end, curvature)` describe the same arc — but only the
8//! sweep is smooth through a straight arc (`k = 0`) and a half circle, and
9//! only the sweep tells a major arc from the minor arc with the same
10//! curvature. The formulas below are written in the half sweep `θ` and never
11//! divide by `sin θ` where avoidable, so a nearly straight arc stays
12//! well-conditioned.
13//!
14//! Every division and square root here is fallible — [`Number::div`] and
15//! [`Number::sqrt`] refuse a divisor or radicand that could be zero/negative
16//! — so a degenerate configuration (a zero-length chord, a collapsed arc)
17//! surfaces as a [`GeopResult`] error instead of silently producing an
18//! `inf`/`nan` that would then have to be caught downstream.
19
20use geop_core_math::{geop_error::GeopResult, scalars::Number, vector::Vector2};
21
22/// A circular arc from `s` to `e`, turning counter-clockwise by `2 * half`
23/// (clockwise if negative).
24#[derive(Clone, Copy, Debug)]
25pub struct Arc<T> {
26    pub s: Vector2<T>,
27    pub e: Vector2<T>,
28    pub half: T,
29}
30
31impl<T: Number> Arc<T> {
32    pub fn chord(&self) -> Vector2<T> {
33        self.e.sub(&self.s)
34    }
35    pub fn chord_length(&self) -> GeopResult<T> {
36        self.chord().try_norm()
37    }
38    pub fn chord_mid(&self) -> Vector2<T> {
39        self.s.add(&self.e).prod_scalar(half::<T>())
40    }
41    /// Unit normal to the chord, pointing to its left: the side the center is
42    /// on for a counter-clockwise minor arc.
43    pub fn left(&self) -> GeopResult<Vector2<T>> {
44        Ok(self.chord().normalize()?.perp())
45    }
46    pub fn curvature(&self) -> GeopResult<T> {
47        T::TWO.mul(self.half.sin()).div(self.chord_length()?)
48    }
49    /// `|radius|`. Infinite for a straight arc.
50    pub fn radius(&self) -> GeopResult<T> {
51        self.chord_length()?.div(T::TWO.mul(self.half.sin().abs()))
52    }
53    /// Center: `chord_mid + left * (L / 2) cot(half)`. Infinitely far for a
54    /// straight arc, so only for constraints that are meaningless there
55    /// anyway (concentricity, tangency to a circle).
56    pub fn center(&self) -> GeopResult<Vector2<T>> {
57        let d = self
58            .chord_length()?
59            .mul(half::<T>())
60            .mul(self.half.cos())
61            .div(self.half.sin())?;
62        Ok(self.chord_mid().add(&self.left()?.prod_scalar(d)))
63    }
64    /// The point halfway along the arc: `chord_mid - left * (L / 2) tan(half / 2)`.
65    pub fn arc_mid(&self) -> GeopResult<Vector2<T>> {
66        let tan_quarter = self.half.sin().div(T::ONE.add(self.half.cos()))?;
67        let sagitta = self.chord_length()?.mul(half::<T>()).mul(tan_quarter);
68        Ok(self.chord_mid().sub(&self.left()?.prod_scalar(sagitta)))
69    }
70    /// Signed distance-like residual of `p` against the arc's full circle,
71    /// finite and smooth for every `half` including a straight arc.
72    ///
73    /// With `q = p - chord_mid`, the circle is `|q|^2 - 2 d (left . q) - L^2/4
74    /// = 0` for `d` the center's offset along `left`. Multiplying by the
75    /// curvature `k` (and using `k d = cos(half)`) gives
76    /// `G = k |q|^2 - 2 cos(half) (left . q) - k L^2 / 4`, which is a line's
77    /// equation at `k = 0`. Near the circle `G ≈ ±2 dist`, so `G / 2` is a
78    /// distance.
79    pub fn circle_residual(&self, p: Vector2<T>) -> GeopResult<T> {
80        let q = p.sub(&self.chord_mid());
81        let k = self.curvature()?;
82        let l = self.chord_length()?;
83        let g = k
84            .mul(q.norm_sq())
85            .sub(T::TWO.mul(self.half.cos()).mul(self.left()?.prod_dot(&q)))
86            .sub(k.mul(l).mul(l).mul(half::<T>().mul(half::<T>())));
87        Ok(g.mul(half::<T>()))
88    }
89    /// Unit tangent at `s`, in the direction of travel.
90    pub fn tangent_start(&self) -> GeopResult<Vector2<T>> {
91        let c = self.chord().normalize()?;
92        Ok(c.rotate(self.half.cos(), self.half.sin().neg()))
93    }
94    /// Unit tangent at `e`, in the direction of travel.
95    pub fn tangent_end(&self) -> GeopResult<Vector2<T>> {
96        let c = self.chord().normalize()?;
97        Ok(c.rotate(self.half.cos(), self.half.sin()))
98    }
99    /// Arc length `L * half / sin(half)`.
100    ///
101    /// Where `sin(half)` could be zero — a straight arc — the quotient is not
102    /// even defined, and the series `x / sin x = 1 + x²/6 + 7x⁴/360 + R(x)`
103    /// stands in, finite with a finite slope through `x = 0`. Its
104    /// coefficients are all positive and shrink by about `π²` each, so for
105    /// `|x| ≤ 1` the rest `R` lies in `[0, x⁶/300]` and its slope in
106    /// `6x⁵ [0, 1/300]`: the term enclosing both is added, so the series is as
107    /// honest an enclosure as the quotient.
108    pub fn length(&self) -> GeopResult<T> {
109        let l = self.chord_length()?;
110        let h = self.half;
111        let sin = h.sin();
112        if sin.definitely_not_equal(T::ZERO) || !h.abs().definitely_less(T::ONE) {
113            return l.mul(h).div(sin);
114        }
115        let ratio = |num, den| T::from_ratio(num, den).expect("a positive denominator");
116        let h2 = h.mul(h);
117        let h4 = h2.mul(h2);
118        let rest = T::ZERO.union(ratio(1, 300)).mul(h4).mul(h2);
119        Ok(l.mul(
120            T::ONE
121                .add(h2.mul(ratio(1, 6)))
122                .add(h4.mul(ratio(7, 360)))
123                .add(rest),
124        ))
125    }
126}
127
128/// `1/2`.
129fn half<T: Number>() -> T {
130    T::ONE.div(T::TWO).expect("2 is not zero")
131}
132
133/// Signed distance of `p` from the line through `a` and `b`, positive on its
134/// left.
135pub fn line_distance<T: Number>(a: Vector2<T>, b: Vector2<T>, p: Vector2<T>) -> GeopResult<T> {
136    let d = b.sub(&a);
137    d.prod_cross(&p.sub(&a)).div(d.try_norm()?)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use geop_core_math::scalars::Scalar;
144
145    fn vec2(x: Scalar, y: Scalar) -> Vector2 {
146        Vector2::from_array([x, y])
147    }
148
149    fn quarter() -> Arc<Scalar> {
150        Arc {
151            s: vec2(Scalar::from_f64(1.0), Scalar::from_f64(0.0)),
152            e: vec2(Scalar::from_f64(0.0), Scalar::from_f64(1.0)),
153            half: Scalar::from_f64(std::f64::consts::FRAC_PI_4),
154        }
155    }
156
157    fn close(a: Vector2, b: [f64; 2]) -> bool {
158        (a[0].to_f64() - b[0]).abs() < 1e-12 && (a[1].to_f64() - b[1]).abs() < 1e-12
159    }
160
161    #[test]
162    fn quarter_circle_has_unit_radius_around_origin() {
163        let a = quarter();
164        assert!(close(a.center().unwrap(), [0.0, 0.0]));
165        assert!((a.radius().unwrap().to_f64() - 1.0).abs() < 1e-12);
166        assert!((a.curvature().unwrap().to_f64() - 1.0).abs() < 1e-12);
167        let s = std::f64::consts::FRAC_1_SQRT_2;
168        assert!(close(a.arc_mid().unwrap(), [s, s]));
169        assert!(close(a.tangent_start().unwrap(), [0.0, 1.0]));
170        assert!(close(a.tangent_end().unwrap(), [-1.0, 0.0]));
171        assert!((a.length().unwrap().to_f64() - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
172        assert!(
173            a.circle_residual(vec2(Scalar::from_f64(-s), Scalar::from_f64(-s)))
174                .unwrap()
175                .to_f64()
176                .abs()
177                < 1e-12
178        );
179        // Outside the circle by 1: residual ≈ distance (exactly `(ρ² - 1)/2`).
180        assert!(
181            (a.circle_residual(vec2(Scalar::from_f64(2.0), Scalar::from_f64(0.0)))
182                .unwrap()
183                .to_f64()
184                - 1.5)
185                .abs()
186                < 1e-12
187        );
188    }
189
190    #[test]
191    fn major_arc_center_is_right_of_chord() {
192        let a = Arc {
193            half: Scalar::from_f64(3.0 * std::f64::consts::FRAC_PI_4),
194            ..quarter()
195        };
196        assert!(close(a.center().unwrap(), [1.0, 1.0]));
197        assert!((a.length().unwrap().to_f64() - 3.0 * std::f64::consts::FRAC_PI_2).abs() < 1e-12);
198    }
199
200    #[test]
201    fn straight_arc_is_its_chord() {
202        let a = Arc {
203            half: Scalar::from_f64(0.0),
204            ..quarter()
205        };
206        assert!((a.length().unwrap().to_f64() - 2f64.sqrt()).abs() < 1e-12);
207        // On the chord's line: zero; to its left by `h`: `-h` (G ≈ -2 left·q).
208        assert!(
209            a.circle_residual(vec2(Scalar::from_f64(0.5), Scalar::from_f64(0.5)))
210                .unwrap()
211                .to_f64()
212                .abs()
213                < 1e-12
214        );
215        assert!(close(a.arc_mid().unwrap(), [0.5, 0.5]));
216    }
217}