axiolid_constraint2d/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Apollonius and tangent-circle constructions (#159, ledger row B14).
4//!
5//! [`tangent_circles`] finds every circle tangent to three given points,
6//! lines or circles, in any combination -- the classical Apollonius problem
7//! (three circles), its ten degenerate variants (mixing in points and
8//! lines), and everything in between.
9//!
10//! # Method
11//!
12//! A tangency to a candidate circle `(c, r)` is one algebraic equation per
13//! constraint, all sharing the same shape after multiplying through:
14//!
15//! `a * (|c|^2 - r^2) + b . c + d * r + f = 0`
16//!
17//! where `a = 1` for a point or circle constraint (`a = 0` for a line), and
18//! a per-constraint sign choice `s` in `{-1, 1}` selects which of the two
19//! tangency senses `d` encodes (internal/external for a circle, which side
20//! for a line; a point has only one sense, so its sign does not change the
21//! equation). Three such equations, one per constraint, are eliminated in
22//! closed form: subtracting the quadratic-type equations from each other
23//! cancels their shared `|c|^2 - r^2` term, leaving two equations linear in
24//! `(cx, cy, r)`; solving those for `cx` and `cy` as affine functions of `r`
25//! and substituting into the remaining quadratic equation gives a quadratic
26//! in `r` alone (linear when every constraint is a line). Every sign choice
27//! (up to eight) is tried; every real root with a positive radius is a
28//! solution, deduplicated and returned in a deterministic order.
29//!
30//! This is the standard "linearization" solution to Apollonius' problem
31//! (see e.g. Coakley 1860, or Gisch and Ribando's 2004 survey for the
32//! algebraic form used here), extended uniformly to points and lines rather
33//! than special-cased.
34//!
35//! # Scope
36//!
37//! Not covered: two circle- or point-type constraints centred at the same
38//! point. Their equations then differ only in the radius term, with no
39//! `cx`/`cy` coefficient left to eliminate against, which is singular under
40//! every sign choice in the elimination this crate uses (a solution family
41//! can still exist; finding it needs a different elimination order). This
42//! is refused as [`TangencyError::Degenerate`], not answered wrong.
43
44use axiolid_core::{Frame2, Point2, Scalar, Tolerance, Vec2};
45use axiolid_curve::Circle2;
46use axiolid_linear::Line2;
47
48/// A point, line or circle a solution circle must be tangent to (a point
49/// counts as a zero-radius circle, tangent when the solution circle passes
50/// through it).
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub enum Tangent {
53    /// A point the solution circle must pass through.
54    Point(Point2),
55    /// A line the solution circle must touch.
56    Line(Line2),
57    /// A circle the solution circle must touch, internally or externally.
58    Circle(Circle2),
59}
60
61/// Why [`tangent_circles`] could not be solved.
62#[derive(Debug, Clone, Copy, PartialEq)]
63#[non_exhaustive]
64pub enum TangencyError {
65    /// A coordinate, direction or radius is not finite.
66    NonFinite,
67    /// Constraint `index` is a circle whose radius is not positive.
68    InvalidRadius {
69        /// The constraint's index (0, 1 or 2).
70        index: usize,
71        /// The radius given.
72        radius: Scalar,
73    },
74    /// Constraint `index` is a line whose direction is shorter than the
75    /// linear tolerance.
76    ZeroDirection {
77        /// The constraint's index (0, 1 or 2).
78        index: usize,
79    },
80    /// Every sign choice's elimination system was singular: the three
81    /// constraints do not pin down a family of tangent circles (for
82    /// example, three coincident points, three identical circles, or three
83    /// parallel lines).
84    Degenerate,
85}
86
87impl core::fmt::Display for TangencyError {
88    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
89        match self {
90            Self::NonFinite => f.write_str("a coordinate, direction or radius is not finite"),
91            Self::InvalidRadius { index, radius } => {
92                write!(f, "constraint {index} has non-positive radius {radius}")
93            }
94            Self::ZeroDirection { index } => {
95                write!(f, "constraint {index} is a line with no direction")
96            }
97            Self::Degenerate => f.write_str("no sign choice pins down a family of tangent circles"),
98        }
99    }
100}
101
102impl core::error::Error for TangencyError {}
103
104/// One tangency equation after a sign choice:
105/// `a * (cx^2 + cy^2 - r^2) + b * cx + c * cy + d * r + f = 0`.
106#[derive(Debug, Clone, Copy)]
107struct Equation {
108    a: Scalar,
109    b: Scalar,
110    c: Scalar,
111    d: Scalar,
112    f: Scalar,
113}
114
115fn equation(constraint: &Tangent, sign: Scalar) -> Equation {
116    match *constraint {
117        Tangent::Point(p) => Equation {
118            a: 1.0,
119            b: -2.0 * p.x,
120            c: -2.0 * p.y,
121            d: 0.0,
122            f: p.x * p.x + p.y * p.y,
123        },
124        Tangent::Circle(circle) => {
125            let o = circle.frame.origin;
126            let rho = circle.radius;
127            Equation {
128                a: 1.0,
129                b: -2.0 * o.x,
130                c: -2.0 * o.y,
131                d: -2.0 * sign * rho,
132                f: o.x * o.x + o.y * o.y - rho * rho,
133            }
134        }
135        Tangent::Line(line) => {
136            let n = line.direction.perp().normalize();
137            Equation {
138                a: 0.0,
139                b: n.x,
140                c: n.y,
141                d: -sign,
142                f: -(n.dot(line.origin)),
143            }
144        }
145    }
146}
147
148fn sub(lhs: &Equation, rhs: &Equation) -> Equation {
149    Equation {
150        a: lhs.a - rhs.a,
151        b: lhs.b - rhs.b,
152        c: lhs.c - rhs.c,
153        d: lhs.d - rhs.d,
154        f: lhs.f - rhs.f,
155    }
156}
157
158/// Every circle tangent to `constraints[0]`, `constraints[1]` and
159/// `constraints[2]`, in any combination of point, line and circle.
160///
161/// Solutions are returned in ascending order of centre `x`, then `y`, then
162/// radius, and deduplicated: two candidates within the linear tolerance of
163/// each other in both centre and radius are the same solution. A well-posed
164/// configuration with no tangent circle (for example, three mutually
165/// separate circles too far apart for any sign choice to reach) answers
166/// with an empty, not an error: only a configuration that pins down no
167/// family of circles at all is refused.
168///
169/// # Errors
170///
171/// [`TangencyError::NonFinite`], [`TangencyError::InvalidRadius`] for a
172/// circle constraint with a non-positive radius,
173/// [`TangencyError::ZeroDirection`] for a line constraint shorter than the
174/// linear tolerance, and [`TangencyError::Degenerate`] when no sign choice
175/// yields a solvable elimination system.
176pub fn tangent_circles(
177    constraints: [Tangent; 3],
178    tolerance: Tolerance,
179) -> Result<Vec<Circle2>, TangencyError> {
180    validate(&constraints, tolerance)?;
181    let linear = tolerance.linear();
182
183    let mut candidates: Vec<(Point2, Scalar)> = Vec::new();
184    let mut any_solvable = false;
185    for bits in 0u8..8 {
186        let signs = [
187            if bits & 1 == 0 { -1.0 } else { 1.0 },
188            if bits & 2 == 0 { -1.0 } else { 1.0 },
189            if bits & 4 == 0 { -1.0 } else { 1.0 },
190        ];
191        let eqs = [
192            equation(&constraints[0], signs[0]),
193            equation(&constraints[1], signs[1]),
194            equation(&constraints[2], signs[2]),
195        ];
196        if let Some(roots) = solve_system(&eqs) {
197            any_solvable = true;
198            for (cx, cy, r) in roots {
199                if r.is_finite() && cx.is_finite() && cy.is_finite() && r > linear {
200                    candidates.push((Point2::new(cx, cy), r));
201                }
202            }
203        }
204    }
205    if !any_solvable {
206        return Err(TangencyError::Degenerate);
207    }
208
209    candidates.sort_by(|a, b| {
210        a.0.x
211            .total_cmp(&b.0.x)
212            .then_with(|| a.0.y.total_cmp(&b.0.y))
213            .then_with(|| a.1.total_cmp(&b.1))
214    });
215    let mut deduped: Vec<(Point2, Scalar)> = Vec::new();
216    for (centre, radius) in candidates {
217        let same = deduped
218            .iter()
219            .any(|&(c, r)| (c - centre).length() <= linear && (r - radius).abs() <= linear);
220        if !same {
221            deduped.push((centre, radius));
222        }
223    }
224
225    Ok(deduped
226        .into_iter()
227        .map(|(centre, radius)| Circle2 {
228            frame: Frame2 {
229                origin: centre,
230                x: Vec2::X,
231                y: Vec2::Y,
232            },
233            radius,
234        })
235        .collect())
236}
237
238fn validate(constraints: &[Tangent; 3], tolerance: Tolerance) -> Result<(), TangencyError> {
239    let linear = tolerance.linear();
240    for (index, constraint) in constraints.iter().enumerate() {
241        match *constraint {
242            Tangent::Point(p) => {
243                if !p.is_finite() {
244                    return Err(TangencyError::NonFinite);
245                }
246            }
247            Tangent::Line(line) => {
248                if !line.origin.is_finite() || !line.direction.is_finite() {
249                    return Err(TangencyError::NonFinite);
250                }
251                if line.direction.length() <= linear {
252                    return Err(TangencyError::ZeroDirection { index });
253                }
254            }
255            Tangent::Circle(circle) => {
256                if !circle.frame.origin.is_finite() || !circle.radius.is_finite() {
257                    return Err(TangencyError::NonFinite);
258                }
259                if circle.radius <= 0.0 {
260                    return Err(TangencyError::InvalidRadius {
261                        index,
262                        radius: circle.radius,
263                    });
264                }
265            }
266        }
267    }
268    Ok(())
269}
270
271/// Solve the 3-equation elimination system for one sign choice: `None` when
272/// it is singular, `Some` with zero, one or two `(cx, cy, r)` roots
273/// otherwise.
274fn solve_system(eqs: &[Equation; 3]) -> Option<Vec<(Scalar, Scalar, Scalar)>> {
275    let quad: Vec<usize> = (0..3).filter(|&i| eqs[i].a != 0.0).collect();
276    if quad.is_empty() {
277        return solve_linear3(eqs).map(|root| vec![root]);
278    }
279    let base = quad[0];
280    let mut lines: Vec<Equation> = Vec::with_capacity(2);
281    for i in 0..3 {
282        if i == base {
283            continue;
284        }
285        lines.push(if eqs[i].a != 0.0 {
286            sub(&eqs[base], &eqs[i])
287        } else {
288            eqs[i]
289        });
290    }
291    let (m1, n1, m2, n2) = solve_cxy_in_r(&lines[0], &lines[1])?;
292
293    let e = &eqs[base];
294    let a_coef = m1 * m1 + m2 * m2 - 1.0;
295    let b_coef = 2.0 * (m1 * n1 + m2 * n2) + e.b * m1 + e.c * m2 + e.d;
296    let c_coef = n1 * n1 + n2 * n2 + e.b * n1 + e.c * n2 + e.f;
297    Some(
298        solve_quadratic(a_coef, b_coef, c_coef)
299            .into_iter()
300            .map(|r| (m1 * r + n1, m2 * r + n2, r))
301            .collect(),
302    )
303}
304
305/// Solve two linear equations `b*cx + c*cy + d*r + f = 0` for `cx` and `cy`
306/// as affine functions of `r`: `cx = m1*r + n1`, `cy = m2*r + n2`. `None`
307/// when the two equations' `(cx, cy)` coefficients are (near-)parallel.
308fn solve_cxy_in_r(l0: &Equation, l1: &Equation) -> Option<(Scalar, Scalar, Scalar, Scalar)> {
309    let det = l0.b * l1.c - l1.b * l0.c;
310    let scale = (l0.b.hypot(l0.c) * l1.b.hypot(l1.c)).max(1.0);
311    if det.abs() <= Scalar::EPSILON * 1e8 * scale {
312        return None;
313    }
314    let m1 = (l1.d * l0.c - l0.d * l1.c) / det;
315    let n1 = (l1.f * l0.c - l0.f * l1.c) / det;
316    let m2 = (l1.b * l0.d - l0.b * l1.d) / det;
317    let n2 = (l1.b * l0.f - l0.b * l1.f) / det;
318    Some((m1, n1, m2, n2))
319}
320
321/// Solve the 3x3 linear system `b_i*cx + c_i*cy + d_i*r + f_i = 0` for
322/// `i = 0, 1, 2` directly (the all-lines case, which has no quadratic
323/// equation to substitute into).
324fn solve_linear3(eqs: &[Equation; 3]) -> Option<(Scalar, Scalar, Scalar)> {
325    let rows = [
326        [eqs[0].b, eqs[0].c, eqs[0].d],
327        [eqs[1].b, eqs[1].c, eqs[1].d],
328        [eqs[2].b, eqs[2].c, eqs[2].d],
329    ];
330    let rhs = [-eqs[0].f, -eqs[1].f, -eqs[2].f];
331    let det = det3(rows);
332    let scale = rows
333        .iter()
334        .map(|row| row[0].hypot(row[1]).hypot(row[2]))
335        .fold(1.0, Scalar::max);
336    if det.abs() <= Scalar::EPSILON * 1e8 * scale * scale * scale {
337        return None;
338    }
339    let cx = det3(replace_col(rows, 0, rhs)) / det;
340    let cy = det3(replace_col(rows, 1, rhs)) / det;
341    let r = det3(replace_col(rows, 2, rhs)) / det;
342    Some((cx, cy, r))
343}
344
345fn det3(m: [[Scalar; 3]; 3]) -> Scalar {
346    m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
347        - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
348        + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
349}
350
351fn replace_col(mut m: [[Scalar; 3]; 3], col: usize, values: [Scalar; 3]) -> [[Scalar; 3]; 3] {
352    for row in 0..3 {
353        m[row][col] = values[row];
354    }
355    m
356}
357
358/// Real roots of `a*x^2 + b*x + c = 0`, falling back to the linear and
359/// constant cases as `a`, then `b`, vanish.
360fn solve_quadratic(a: Scalar, b: Scalar, c: Scalar) -> Vec<Scalar> {
361    let scale = a.abs().max(b.abs()).max(c.abs()).max(1.0);
362    if a.abs() <= Scalar::EPSILON * 1e8 * scale {
363        if b.abs() <= Scalar::EPSILON * 1e8 * scale {
364            return Vec::new();
365        }
366        return vec![-c / b];
367    }
368    let discriminant = b * b - 4.0 * a * c;
369    if discriminant < 0.0 {
370        return Vec::new();
371    }
372    if discriminant <= Scalar::EPSILON * 1e8 * scale * scale {
373        return vec![-b / (2.0 * a)];
374    }
375    let sqrt_d = discriminant.sqrt();
376    // The numerically stable form (Press et al.): avoids cancellation when
377    // `b` and `sqrt_d` are close in magnitude and the same sign.
378    let q = if b >= 0.0 {
379        -0.5 * (b + sqrt_d)
380    } else {
381        -0.5 * (b - sqrt_d)
382    };
383    if q.abs() <= Scalar::EPSILON * 1e8 * scale {
384        return vec![-b / (2.0 * a)];
385    }
386    let mut roots = vec![q / a, c / q];
387    roots.sort_by(Scalar::total_cmp);
388    roots
389}