Skip to main content

geop_core_sketch/
geometry.rs

1//! The geometry of sketch entities, generic over [`Scalar`] so every formula
2//! serves both the residuals (differentiated with [`crate::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 — [`Scalar::div`] and
15//! [`Scalar::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::Scalar};
21
22/// A 2-D vector.
23#[derive(Clone, Copy, Debug)]
24pub struct V<T> {
25    pub x: T,
26    pub y: T,
27}
28
29// `add`/`sub`/`dot`/... mirror the kernel's own `Vector` API, so the same
30// formulas read the same way here as they do there.
31#[allow(clippy::should_implement_trait)]
32impl<T: Scalar> V<T> {
33    pub fn new(x: T, y: T) -> Self {
34        V { x, y }
35    }
36    pub fn cst(p: [f64; 2]) -> Self {
37        V::new(T::from_f64(p[0]), T::from_f64(p[1]))
38    }
39    pub fn add(self, o: Self) -> Self {
40        V::new(self.x.add(o.x), self.y.add(o.y))
41    }
42    pub fn sub(self, o: Self) -> Self {
43        V::new(self.x.sub(o.x), self.y.sub(o.y))
44    }
45    pub fn scale(self, s: T) -> Self {
46        V::new(self.x.mul(s), self.y.mul(s))
47    }
48    pub fn dot(self, o: Self) -> T {
49        self.x.mul(o.x).add(self.y.mul(o.y))
50    }
51    pub fn cross(self, o: Self) -> T {
52        self.x.mul(o.y).sub(self.y.mul(o.x))
53    }
54    pub fn norm(self) -> GeopResult<T> {
55        self.dot(self).sqrt()
56    }
57    /// Rotated 90 degrees counter-clockwise.
58    pub fn perp(self) -> Self {
59        V::new(T::ZERO.sub(self.y), self.x)
60    }
61    pub fn unit(self) -> GeopResult<Self> {
62        Ok(self.scale(T::ONE.div(self.norm()?)?))
63    }
64    /// Rotated by the angle with cosine `c` and sine `s`.
65    pub fn rotate(self, c: T, s: T) -> Self {
66        V::new(
67            self.x.mul(c).sub(self.y.mul(s)),
68            self.x.mul(s).add(self.y.mul(c)),
69        )
70    }
71    pub fn value(self) -> [f64; 2] {
72        [self.x.to_f64(), self.y.to_f64()]
73    }
74}
75
76/// A circular arc from `s` to `e`, turning counter-clockwise by `2 * half`
77/// (clockwise if negative).
78#[derive(Clone, Copy, Debug)]
79pub struct Arc<T> {
80    pub s: V<T>,
81    pub e: V<T>,
82    pub half: T,
83}
84
85impl<T: Scalar> Arc<T> {
86    pub fn chord(&self) -> V<T> {
87        self.e.sub(self.s)
88    }
89    pub fn chord_length(&self) -> GeopResult<T> {
90        self.chord().norm()
91    }
92    pub fn chord_mid(&self) -> V<T> {
93        self.s.add(self.e).scale(T::from_f64(0.5))
94    }
95    /// Unit normal to the chord, pointing to its left: the side the center is
96    /// on for a counter-clockwise minor arc.
97    pub fn left(&self) -> GeopResult<V<T>> {
98        Ok(self.chord().unit()?.perp())
99    }
100    pub fn curvature(&self) -> GeopResult<T> {
101        T::TWO.mul(self.half.sin()).div(self.chord_length()?)
102    }
103    /// `|radius|`. Infinite for a straight arc.
104    pub fn radius(&self) -> GeopResult<T> {
105        self.chord_length()?.div(T::TWO.mul(self.half.sin().abs()))
106    }
107    /// Center: `chord_mid + left * (L / 2) cot(half)`. Infinitely far for a
108    /// straight arc, so only for constraints that are meaningless there
109    /// anyway (concentricity, tangency to a circle).
110    pub fn center(&self) -> GeopResult<V<T>> {
111        let d = self
112            .chord_length()?
113            .mul(T::from_f64(0.5))
114            .mul(self.half.cos())
115            .div(self.half.sin())?;
116        Ok(self.chord_mid().add(self.left()?.scale(d)))
117    }
118    /// The point halfway along the arc: `chord_mid - left * (L / 2) tan(half / 2)`.
119    pub fn arc_mid(&self) -> GeopResult<V<T>> {
120        let tan_quarter = self.half.sin().div(T::ONE.add(self.half.cos()))?;
121        let sagitta = self.chord_length()?.mul(T::from_f64(0.5)).mul(tan_quarter);
122        Ok(self.chord_mid().sub(self.left()?.scale(sagitta)))
123    }
124    /// Signed distance-like residual of `p` against the arc's full circle,
125    /// finite and smooth for every `half` including a straight arc.
126    ///
127    /// With `q = p - chord_mid`, the circle is `|q|^2 - 2 d (left . q) - L^2/4
128    /// = 0` for `d` the center's offset along `left`. Multiplying by the
129    /// curvature `k` (and using `k d = cos(half)`) gives
130    /// `G = k |q|^2 - 2 cos(half) (left . q) - k L^2 / 4`, which is a line's
131    /// equation at `k = 0`. Near the circle `G ≈ ±2 dist`, so `G / 2` is a
132    /// distance.
133    pub fn circle_residual(&self, p: V<T>) -> GeopResult<T> {
134        let q = p.sub(self.chord_mid());
135        let k = self.curvature()?;
136        let l = self.chord_length()?;
137        let g = k
138            .mul(q.dot(q))
139            .sub(T::TWO.mul(self.half.cos()).mul(self.left()?.dot(q)))
140            .sub(k.mul(l).mul(l).mul(T::from_f64(0.25)));
141        Ok(g.mul(T::from_f64(0.5)))
142    }
143    /// Unit tangent at `s`, in the direction of travel.
144    pub fn tangent_start(&self) -> GeopResult<V<T>> {
145        let c = self.chord().unit()?;
146        Ok(c.rotate(self.half.cos(), T::ZERO.sub(self.half.sin())))
147    }
148    /// Unit tangent at `e`, in the direction of travel.
149    pub fn tangent_end(&self) -> GeopResult<V<T>> {
150        let c = self.chord().unit()?;
151        Ok(c.rotate(self.half.cos(), self.half.sin()))
152    }
153    /// Arc length `L * half / sin(half)`.
154    pub fn length(&self) -> GeopResult<T> {
155        let l = self.chord_length()?;
156        let h = self.half;
157        Ok(if h.to_f64().abs() < 1e-4 {
158            // Series of `x / sin x`, to keep the derivative finite at 0.
159            l.mul(
160                T::ONE
161                    .add(h.mul(h).mul(T::from_f64(1.0 / 6.0)))
162                    .add(h.mul(h).mul(h).mul(h).mul(T::from_f64(7.0 / 360.0))),
163            )
164        } else {
165            l.mul(h).div(h.sin())?
166        })
167    }
168}
169
170/// Signed distance of `p` from the line through `a` and `b`, positive on its
171/// left.
172pub fn line_distance<T: Scalar>(a: V<T>, b: V<T>, p: V<T>) -> GeopResult<T> {
173    let d = b.sub(a);
174    d.cross(p.sub(a)).div(d.norm()?)
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use geop_core_math::scalars::scal_in_f64::ScalInF64;
181
182    fn quarter() -> Arc<ScalInF64> {
183        Arc {
184            s: V::new(ScalInF64::from_f64(1.0), ScalInF64::from_f64(0.0)),
185            e: V::new(ScalInF64::from_f64(0.0), ScalInF64::from_f64(1.0)),
186            half: ScalInF64::from_f64(std::f64::consts::FRAC_PI_4),
187        }
188    }
189
190    fn close(a: [f64; 2], b: [f64; 2]) -> bool {
191        (a[0] - b[0]).abs() < 1e-12 && (a[1] - b[1]).abs() < 1e-12
192    }
193
194    #[test]
195    fn quarter_circle_has_unit_radius_around_origin() {
196        let a = quarter();
197        assert!(close(a.center().unwrap().value(), [0.0, 0.0]));
198        assert!((a.radius().unwrap().to_f64() - 1.0).abs() < 1e-12);
199        assert!((a.curvature().unwrap().to_f64() - 1.0).abs() < 1e-12);
200        let s = std::f64::consts::FRAC_1_SQRT_2;
201        assert!(close(a.arc_mid().unwrap().value(), [s, s]));
202        assert!(close(a.tangent_start().unwrap().value(), [0.0, 1.0]));
203        assert!(close(a.tangent_end().unwrap().value(), [-1.0, 0.0]));
204        assert!((a.length().unwrap().to_f64() - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
205        assert!(
206            a.circle_residual(V::new(ScalInF64::from_f64(-s), ScalInF64::from_f64(-s)))
207                .unwrap()
208                .to_f64()
209                .abs()
210                < 1e-12
211        );
212        // Outside the circle by 1: residual ≈ distance (exactly `(ρ² - 1)/2`).
213        assert!(
214            (a.circle_residual(V::new(ScalInF64::from_f64(2.0), ScalInF64::from_f64(0.0)))
215                .unwrap()
216                .to_f64()
217                - 1.5)
218                .abs()
219                < 1e-12
220        );
221    }
222
223    #[test]
224    fn major_arc_center_is_right_of_chord() {
225        let a = Arc {
226            half: ScalInF64::from_f64(3.0 * std::f64::consts::FRAC_PI_4),
227            ..quarter()
228        };
229        assert!(close(a.center().unwrap().value(), [1.0, 1.0]));
230        assert!((a.length().unwrap().to_f64() - 3.0 * std::f64::consts::FRAC_PI_2).abs() < 1e-12);
231    }
232
233    #[test]
234    fn straight_arc_is_its_chord() {
235        let a = Arc {
236            half: ScalInF64::from_f64(0.0),
237            ..quarter()
238        };
239        assert!((a.length().unwrap().to_f64() - 2f64.sqrt()).abs() < 1e-12);
240        // On the chord's line: zero; to its left by `h`: `-h` (G ≈ -2 left·q).
241        assert!(
242            a.circle_residual(V::new(ScalInF64::from_f64(0.5), ScalInF64::from_f64(0.5)))
243                .unwrap()
244                .to_f64()
245                .abs()
246                < 1e-12
247        );
248        assert!(close(a.arc_mid().unwrap().value(), [0.5, 0.5]));
249    }
250}