Skip to main content

ogeom_math/
construct2d.rs

1//! The classical 2D constructions: circles tangent to three entities,
2//! tangent lines, and bisector curves: the straightedge-and-compass
3//! repertoire, solved algebraically.
4//!
5//! One linearization carries the whole tangency family. A circle with
6//! centre `c` and radius `r` is tangent to a target once a *side* is
7//! chosen, and with that side fixed every constraint is linear in
8//! `(c, r, Q)` where `Q = |c|² − r²`:
9//!
10//! - a target circle `(cᵢ, rᵢ)` on side `sᵢ`: `Q − 2cᵢ·c − 2sᵢrᵢr = rᵢ² − |cᵢ|²`;
11//! - a point is a zero-radius circle;
12//! - a line with unit normal `n` and offset `d` on side `t`: `n·c − t·r = d`,
13//!   with no `Q` at all.
14//!
15//! Three constraints give three linear equations in at most four unknowns;
16//! the solution family is a line, and re-imposing `Q = |c|² − r²` is a
17//! quadratic along it. Enumerating the sides, solving, and *verifying every
18//! candidate against the literal tangency distances* (the linearization can
19//! manufacture roots the geometry rejects) yields exactly the classical
20//! solution sets, Apollonius's eight included.
21
22use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
23
24use crate::conic::{Circle2, Ellipse2, Hyperbola2, Parabola2};
25use crate::direction::Direction2;
26use crate::frame::{Axis2, Frame2};
27use crate::point::Point2;
28use crate::vector::Vector2;
29
30/// An entity a construction can be tangent to, or equidistant from.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub enum Target2 {
33    /// A point: a zero-radius circle for tangency, itself for distance.
34    Point(Point2),
35    /// An unbounded line.
36    Line(Axis2),
37    /// A circle.
38    Circle(Circle2),
39}
40
41impl Target2 {
42    /// The distance from `p` to this target's own locus (for a circle, the
43    /// distance to its *boundary*).
44    #[must_use]
45    pub fn distance_to(&self, p: Point2) -> f64 {
46        match self {
47            Self::Point(q) => p.distance(*q),
48            Self::Line(axis) => axis.distance_to(p),
49            Self::Circle(c) => (p.distance(c.centre()) - c.radius()).abs(),
50        }
51    }
52}
53
54/// How a tangent circle stands to one of its targets.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Placement {
57    /// Passes through a point target.
58    Through,
59    /// Touches a line target.
60    Tangent,
61    /// Touches a circle target from outside. The circles exclude each
62    /// other.
63    Outside,
64    /// The solution contains the target circle.
65    Enclosing,
66    /// The target circle contains the solution.
67    Enclosed,
68}
69
70/// One tangent circle, with its standing toward each target in order.
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct TangentCircle {
73    /// The solution.
74    pub circle: Circle2,
75    /// How it stands to each target, in the order they were given.
76    pub placements: [Placement; 3],
77}
78
79/// A row of the linearized system: coefficients of `(cx, cy, r, Q)` and the
80/// right-hand side.
81type Row = ([f64; 4], f64);
82
83fn rows_for(target: &Target2, side: f64) -> Row {
84    match target {
85        Target2::Point(p) => {
86            // Q − 2p·c = −|p|²
87            ([-2.0 * p.x, -2.0 * p.y, 0.0, 1.0], -(p.x * p.x + p.y * p.y))
88        }
89        Target2::Circle(c) => {
90            let centre = c.centre();
91            let r = c.radius();
92            (
93                [-2.0 * centre.x, -2.0 * centre.y, -2.0 * side * r, 1.0],
94                r * r - (centre.x * centre.x + centre.y * centre.y),
95            )
96        }
97        Target2::Line(axis) => {
98            let n = normal_of(axis);
99            let d = n.dot(axis.location.to_vector());
100            ([n.x, n.y, -side, 0.0], d)
101        }
102    }
103}
104
105/// The unit left normal of a line.
106fn normal_of(axis: &Axis2) -> Vector2 {
107    let d = axis.direction.vector();
108    Vector2::new(-d.y, d.x)
109}
110
111/// The sides to enumerate for one target: points have no side.
112fn sides_of(target: &Target2) -> &'static [f64] {
113    match target {
114        Target2::Point(_) => &[1.0],
115        _ => &[1.0, -1.0],
116    }
117}
118
119/// Circles tangent to all three targets: the Apollonius family and its
120/// degenerate relatives, every candidate verified against the literal
121/// tangency distances before it is returned.
122///
123/// # Errors
124///
125/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if two
126/// targets coincide, which asks a different, underdetermined question.
127pub fn circles_tangent_to_three(
128    targets: &[Target2; 3],
129    tol: Tolerances,
130) -> OgeomResult<Vec<TangentCircle>> {
131    // Solved about the targets' own middle: the linearized rows carry each
132    // anchor's squared distance from the origin, and far from it those
133    // squares cancel to a few digits.
134    let shift = middle(targets);
135    let near = [
136        shifted(&targets[0], -shift, tol)?,
137        shifted(&targets[1], -shift, tol)?,
138        shifted(&targets[2], -shift, tol)?,
139    ];
140    let found = tangent_to_three_near(&near, tol)?;
141    found.into_iter().map(|t| moved(t, shift, tol)).collect()
142}
143
144fn tangent_to_three_near(
145    targets: &[Target2; 3],
146    tol: Tolerances,
147) -> OgeomResult<Vec<TangentCircle>> {
148    for i in 0..3 {
149        for j in i + 1..3 {
150            if targets_coincide(&targets[i], &targets[j], tol) {
151                ogeom_bail!(
152                    Construction,
153                    "targets {i} and {j} coincide; the tangency family is underdetermined"
154                );
155            }
156        }
157    }
158    let mut out: Vec<TangentCircle> = Vec::new();
159    for &s0 in sides_of(&targets[0]) {
160        for &s1 in sides_of(&targets[1]) {
161            for &s2 in sides_of(&targets[2]) {
162                let rows = [
163                    rows_for(&targets[0], s0),
164                    rows_for(&targets[1], s1),
165                    rows_for(&targets[2], s2),
166                ];
167                for candidate in solve_rows(&rows, tol) {
168                    admit(&mut out, candidate, targets, tol);
169                }
170            }
171        }
172    }
173    Ok(out)
174}
175
176/// Circles of a fixed radius tangent to two targets: the same machinery
177/// with the radius row supplied.
178///
179/// # Errors
180///
181/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
182/// radius is not finite and positive, or the targets coincide.
183pub fn circles_of_radius_tangent_to_two(
184    radius: f64,
185    targets: &[Target2; 2],
186    tol: Tolerances,
187) -> OgeomResult<Vec<TangentCircle>> {
188    // About the targets' middle, as for three.
189    let shift = middle(targets);
190    let near = [
191        shifted(&targets[0], -shift, tol)?,
192        shifted(&targets[1], -shift, tol)?,
193    ];
194    let found = of_radius_near(radius, &near, tol)?;
195    found.into_iter().map(|t| moved(t, shift, tol)).collect()
196}
197
198/// Where a set of targets stands: the mean of their anchors (a point, a
199/// circle's centre, a line's stated location).
200fn middle(targets: &[Target2]) -> Vector2 {
201    let mut sum = Vector2::new(0.0, 0.0);
202    for target in targets {
203        sum += match target {
204            Target2::Point(p) => p.to_vector(),
205            Target2::Circle(c) => c.centre().to_vector(),
206            Target2::Line(axis) => axis.location.to_vector(),
207        };
208    }
209    #[allow(clippy::cast_precision_loss)]
210    let count = targets.len().max(1) as f64;
211    sum * (1.0 / count)
212}
213
214/// A target moved by `by`.
215fn shifted(target: &Target2, by: Vector2, tol: Tolerances) -> OgeomResult<Target2> {
216    Ok(match target {
217        Target2::Point(p) => Target2::Point(*p + by),
218        Target2::Line(axis) => Target2::Line(Axis2::new(axis.location + by, axis.direction)),
219        Target2::Circle(c) => Target2::Circle(Circle2::new(
220            Frame2::new(c.centre() + by, c.frame().x()),
221            c.radius(),
222            tol,
223        )?),
224    })
225}
226
227/// A solution moved by `by`; its standings move with it.
228fn moved(found: TangentCircle, by: Vector2, tol: Tolerances) -> OgeomResult<TangentCircle> {
229    let c = found.circle;
230    Ok(TangentCircle {
231        circle: Circle2::new(Frame2::new(c.centre() + by, c.frame().x()), c.radius(), tol)?,
232        placements: found.placements,
233    })
234}
235
236fn of_radius_near(
237    radius: f64,
238    targets: &[Target2; 2],
239    tol: Tolerances,
240) -> OgeomResult<Vec<TangentCircle>> {
241    if !radius.is_finite() || radius <= tol.confusion() {
242        ogeom_bail!(
243            Construction,
244            "a tangent circle of radius {radius} is not a circle"
245        );
246    }
247    if targets_coincide(&targets[0], &targets[1], tol) {
248        ogeom_bail!(Construction, "the two targets coincide");
249    }
250    let mut out: Vec<TangentCircle> = Vec::new();
251    let radius_row: Row = ([0.0, 0.0, 1.0, 0.0], radius);
252    for &s0 in sides_of(&targets[0]) {
253        for &s1 in sides_of(&targets[1]) {
254            let rows = [
255                rows_for(&targets[0], s0),
256                rows_for(&targets[1], s1),
257                radius_row,
258            ];
259            for candidate in solve_rows(&rows, tol) {
260                let three = [targets[0], targets[1], targets[1]];
261                let mut kept = out.clone();
262                admit(&mut kept, candidate, &three, tol);
263                // Re-derive placements for the two real targets only.
264                if kept.len() > out.len() {
265                    let solution = kept[kept.len() - 1].circle;
266                    let placements = [
267                        placement_of(&solution, &targets[0], tol),
268                        placement_of(&solution, &targets[1], tol),
269                        placement_of(&solution, &targets[1], tol),
270                    ];
271                    out.push(TangentCircle {
272                        circle: solution,
273                        placements,
274                    });
275                }
276            }
277        }
278    }
279    Ok(out)
280}
281
282/// The up-to-four lines tangent to two circles: the external pair where the
283/// circles lie on the same side, the internal pair where they straddle. A
284/// pair whose circles touch is the one line through the touching point.
285#[must_use]
286pub fn lines_tangent_to_two_circles(a: &Circle2, b: &Circle2, tol: Tolerances) -> Vec<Axis2> {
287    let e = b.centre() - a.centre();
288    let distance = e.magnitude();
289    if distance <= tol.confusion() {
290        return Vec::new();
291    }
292    let along = e / distance;
293    let across = Vector2::new(-along.y, along.x);
294    let mut out = Vec::new();
295    // Unit normal n with n·(ca − cb) = s_a·ra − s_b·rb, d = n·ca − s_a·ra.
296    for (sa, sb) in [(1.0, 1.0), (1.0, -1.0)] {
297        let reach = sa * a.radius() - sb * b.radius();
298        // How far the circles stand from touching for this pair: past it
299        // there is no line, at it the pair is one.
300        let gap = reach.abs() - distance;
301        if gap > tol.confusion() {
302            continue;
303        }
304        let touching = gap.abs() <= tol.confusion();
305        let k = (reach / distance).clamp(-1.0, 1.0);
306        let across_part = if touching { 0.0 } else { (1.0 - k * k).sqrt() };
307        let flips: &[f64] = if touching { &[1.0] } else { &[1.0, -1.0] };
308        for &flip in flips {
309            let n = along * -k + across * (across_part * flip);
310            let d = n.dot(a.centre().to_vector()) - sa * a.radius();
311            // The line's own frame: direction perpendicular to n, located at
312            // the foot nearest the midpoint of the centres.
313            let mid = a.centre() + e * 0.5;
314            let foot = mid - n * (n.dot(mid.to_vector()) - d);
315            if let Ok(direction) = Direction2::new(Vector2::new(n.y, -n.x), tol) {
316                out.push(Axis2::new(foot, direction));
317            }
318        }
319    }
320    out
321}
322
323/// Solve three rows for `(c, r)` candidates: direct where `Q` is absent,
324/// the line-family-plus-quadratic where it is present.
325fn solve_rows(rows: &[Row; 3], tol: Tolerances) -> Vec<(Point2, f64)> {
326    let uses_q = rows.iter().any(|(coeffs, _)| coeffs[3] != 0.0);
327    if uses_q {
328        solve_with_q(rows, tol)
329    } else {
330        solve_linear(rows, tol)
331    }
332}
333
334/// All-lines: three equations in `(cx, cy, r)`.
335fn solve_linear(rows: &[Row; 3], _tol: Tolerances) -> Vec<(Point2, f64)> {
336    let m = nalgebra::Matrix3::new(
337        rows[0].0[0],
338        rows[0].0[1],
339        rows[0].0[2],
340        rows[1].0[0],
341        rows[1].0[1],
342        rows[1].0[2],
343        rows[2].0[0],
344        rows[2].0[1],
345        rows[2].0[2],
346    );
347    let b = nalgebra::Vector3::new(rows[0].1, rows[1].1, rows[2].1);
348    let Some(solution) = m.lu().solve(&b) else {
349        return Vec::new();
350    };
351    vec![(Point2::new(solution[0], solution[1]), solution[2])]
352}
353
354/// With `Q` present: the 3×4 system's solution line, cut by the quadratic
355/// `|c|² − r² − Q = 0`. The null vector comes from the four 3×3 minors
356/// (the generalized cross product) and the particular solution from the
357/// best-conditioned 3×3 subsystem with the remaining unknown pinned to
358/// zero.
359fn solve_with_q(rows: &[Row; 3], tol: Tolerances) -> Vec<(Point2, f64)> {
360    let m = [rows[0].0, rows[1].0, rows[2].0];
361    let b = [rows[0].1, rows[1].1, rows[2].1];
362
363    // Minor j: the determinant with column j removed, alternating sign.
364    let minor = |skip: usize| -> f64 {
365        let cols: Vec<usize> = (0..4).filter(|c| *c != skip).collect();
366
367        nalgebra::Matrix3::new(
368            m[0][cols[0]],
369            m[0][cols[1]],
370            m[0][cols[2]],
371            m[1][cols[0]],
372            m[1][cols[1]],
373            m[1][cols[2]],
374            m[2][cols[0]],
375            m[2][cols[1]],
376            m[2][cols[2]],
377        )
378        .determinant()
379    };
380    let null: [f64; 4] = [minor(0), -minor(1), minor(2), -minor(3)];
381    let biggest = null.iter().fold(0.0_f64, |a, v| a.max(v.abs()));
382    if biggest <= 1e-12 {
383        // Rank below three: a degenerate side pattern.
384        return Vec::new();
385    }
386
387    // Particular solution: pin the unknown whose removal leaves the
388    // best-conditioned square system.
389    let pin = (0..4)
390        .max_by(|a, b| {
391            minor(*a)
392                .abs()
393                .partial_cmp(&minor(*b).abs())
394                .unwrap_or(core::cmp::Ordering::Equal)
395        })
396        .unwrap_or(3);
397    let cols: Vec<usize> = (0..4).filter(|c| *c != pin).collect();
398    let square = nalgebra::Matrix3::new(
399        m[0][cols[0]],
400        m[0][cols[1]],
401        m[0][cols[2]],
402        m[1][cols[0]],
403        m[1][cols[1]],
404        m[1][cols[2]],
405        m[2][cols[0]],
406        m[2][cols[1]],
407        m[2][cols[2]],
408    );
409    let rhs = nalgebra::Vector3::new(b[0], b[1], b[2]);
410    let Some(solved) = square.lu().solve(&rhs) else {
411        return Vec::new();
412    };
413    let mut particular = [0.0f64; 4];
414    for (slot, col) in cols.iter().enumerate() {
415        particular[*col] = solved[slot];
416    }
417
418    // g(λ) = |c|² − r² − Q along x = particular + λ·null.
419    let (px, py, pr, pq) = (particular[0], particular[1], particular[2], particular[3]);
420    let (nx, ny, nr, nq) = (null[0], null[1], null[2], null[3]);
421    let a2 = nx * nx + ny * ny - nr * nr;
422    let a1 = 2.0 * (px * nx + py * ny - pr * nr) - nq;
423    let a0 = px * px + py * py - pr * pr - pq;
424
425    let mut lambdas = Vec::new();
426    if a2.abs() <= 1e-14 * (a1.abs().max(a0.abs()).max(1.0)) {
427        if a1.abs() > 1e-14 {
428            lambdas.push(-a0 / a1);
429        }
430    } else {
431        // The quadratic's vertex is always a candidate: a tangent
432        // configuration's double root sits exactly there, and rounding
433        // renders its discriminant a hair negative. The literal tangency
434        // verification downstream rejects the vertex whenever it is not a
435        // real solution, so offering it costs nothing and loses nothing.
436        lambdas.push(-a1 / (2.0 * a2));
437        let disc = a1.mul_add(a1, -4.0 * a2 * a0);
438        if disc > 0.0 {
439            let root = disc.sqrt();
440            lambdas.push((-a1 + root) / (2.0 * a2));
441            lambdas.push((-a1 - root) / (2.0 * a2));
442        }
443    }
444    lambdas
445        .into_iter()
446        .map(|l| (Point2::new(px + l * nx, py + l * ny), pr + l * nr))
447        .filter(|(_, r)| r.is_finite() && *r > tol.confusion())
448        .collect()
449}
450
451/// Verify a candidate against the literal tangency distances and admit it
452/// once.
453fn admit(
454    out: &mut Vec<TangentCircle>,
455    (centre, radius): (Point2, f64),
456    targets: &[Target2; 3],
457    tol: Tolerances,
458) {
459    let slack = tol.confusion() * 1e3 * radius.max(1.0);
460    for target in targets {
461        let touch = match target {
462            Target2::Point(p) => (centre.distance(*p) - radius).abs(),
463            Target2::Line(axis) => (axis.distance_to(centre) - radius).abs(),
464            Target2::Circle(c) => {
465                let d = centre.distance(c.centre());
466                (d - (radius + c.radius()))
467                    .abs()
468                    .min((d - (radius - c.radius()).abs()).abs())
469            }
470        };
471        if touch > slack {
472            return;
473        }
474    }
475    if out.iter().any(|held| {
476        held.circle.centre().distance(centre) <= slack
477            && (held.circle.radius() - radius).abs() <= slack
478    }) {
479        return;
480    }
481    let Ok(circle) = Circle2::new(Frame2::new(centre, Direction2::X), radius, tol) else {
482        return;
483    };
484    let placements = [
485        placement_of(&circle, &targets[0], tol),
486        placement_of(&circle, &targets[1], tol),
487        placement_of(&circle, &targets[2], tol),
488    ];
489    out.push(TangentCircle { circle, placements });
490}
491
492fn placement_of(circle: &Circle2, target: &Target2, tol: Tolerances) -> Placement {
493    match target {
494        Target2::Point(_) => Placement::Through,
495        Target2::Line(_) => Placement::Tangent,
496        Target2::Circle(c) => {
497            let d = circle.centre().distance(c.centre());
498            let slack = tol.confusion() * 1e3 * circle.radius().max(1.0);
499            if (d - (circle.radius() + c.radius())).abs() <= slack {
500                Placement::Outside
501            } else if circle.radius() >= c.radius()
502                && (d - (circle.radius() - c.radius())).abs() <= slack
503            {
504                Placement::Enclosing
505            } else {
506                Placement::Enclosed
507            }
508        }
509    }
510}
511
512fn targets_coincide(a: &Target2, b: &Target2, tol: Tolerances) -> bool {
513    match (a, b) {
514        (Target2::Point(p), Target2::Point(q)) => p.is_equal(*q, tol),
515        (Target2::Circle(c), Target2::Circle(d)) => {
516            c.centre().is_equal(d.centre(), tol)
517                && (c.radius() - d.radius()).abs() <= tol.confusion()
518        }
519        (Target2::Line(a), Target2::Line(b)) => {
520            let na = normal_of(a);
521            let nb = normal_of(b);
522            na.cross(nb).abs() <= tol.angular() && a.distance_to(b.location) <= tol.confusion()
523        }
524        _ => false,
525    }
526}
527
528// --- bisectors ---------------------------------------------------------------
529
530/// The locus of points equidistant from two targets.
531#[derive(Debug, Clone, Copy, PartialEq)]
532pub enum Bisector2 {
533    /// A single line: two points, or two parallel lines.
534    Line(Axis2),
535    /// The two angle bisectors of intersecting lines.
536    Pair([Axis2; 2]),
537    /// Point against line, or line against circle: a parabola.
538    Parabola(Parabola2),
539    /// A point inside a circle, or nested circles: an ellipse.
540    Ellipse(Ellipse2),
541    /// A point outside a circle, or circles of unequal radius: a hyperbola.
542    /// The equidistant locus is the branch on the frame's `+x` side, toward
543    /// the point, or toward the smaller circle. The mirror branch comes with
544    /// the conic but bisects nothing.
545    Hyperbola(Hyperbola2),
546}
547
548/// The bisector of two targets: the equidistant locus, as the conic it is.
549///
550/// For circle targets the distance is to the *boundary*, which is what makes
551/// the answer a conic with the centres as foci.
552///
553/// # Errors
554///
555/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
556/// targets coincide, or a point lies on a line or circle target; the locus
557/// then degenerates to something that is not a curve.
558pub fn bisector(a: &Target2, b: &Target2, tol: Tolerances) -> OgeomResult<Bisector2> {
559    if targets_coincide(a, b, tol) {
560        ogeom_bail!(Construction, "coincident targets bisect everywhere");
561    }
562    // Normalize the order so each pair is handled once.
563    match (a, b) {
564        (Target2::Point(p), Target2::Point(q)) => {
565            let mid = *p + (*q - *p) * 0.5;
566            let direction = Direction2::new(perp(*q - *p), tol)?;
567            Ok(Bisector2::Line(Axis2::new(mid, direction)))
568        }
569
570        (Target2::Line(l), Target2::Line(m)) => {
571            let nl = normal_of(l);
572            let nm = normal_of(m);
573            let dl = nl.dot(l.location.to_vector());
574            let dm = nm.dot(m.location.to_vector());
575            if nl.cross(nm).abs() <= tol.angular() {
576                // Parallel: the midline. Align the normals first.
577                let (nm, dm) = if nl.dot(nm) < 0.0 {
578                    (-nm, -dm)
579                } else {
580                    (nm, dm)
581                };
582                let _ = nm;
583                let offset = f64::midpoint(dl, dm);
584                let foot = Point2::new(nl.x * offset, nl.y * offset);
585                return Ok(Bisector2::Line(Axis2::new(foot, l.direction)));
586            }
587            // Intersecting: n_l·x − d_l = ±(n_m·x − d_m).
588            let apex = intersect_lines(nl, dl, nm, dm)?;
589            let d1 = Direction2::new(l.direction.vector() + m.direction.vector(), tol)
590                .or_else(|_| Direction2::new(perp(l.direction.vector()), tol))?;
591            let d2 = Direction2::new(perp(d1.vector()), tol)?;
592            Ok(Bisector2::Pair([
593                Axis2::new(apex, d1),
594                Axis2::new(apex, d2),
595            ]))
596        }
597
598        (Target2::Point(p), Target2::Line(l)) | (Target2::Line(l), Target2::Point(p)) => {
599            let n = normal_of(l);
600            let signed = n.dot(*p - l.location);
601            if signed.abs() <= tol.confusion() {
602                ogeom_bail!(
603                    Construction,
604                    "the point lies on the line; the locus degenerates"
605                );
606            }
607            // Focus at the point, directrix the line: apex midway, opening
608            // from the line toward the point.
609            let foot = *p - n * signed;
610            let apex = foot + (*p - foot) * 0.5;
611            let x = Direction2::new(*p - foot, tol)?;
612            let frame = Frame2::new(apex, x);
613            Ok(Bisector2::Parabola(Parabola2::new(
614                frame,
615                signed.abs() / 2.0,
616                tol,
617            )?))
618        }
619
620        (Target2::Point(p), Target2::Circle(c)) | (Target2::Circle(c), Target2::Point(p)) => {
621            let spread = p.distance(c.centre());
622            let r = c.radius();
623            if (spread - r).abs() <= tol.confusion() {
624                ogeom_bail!(
625                    Construction,
626                    "the point lies on the circle; the locus degenerates"
627                );
628            }
629            foci_conic(c.centre(), *p, r, spread, tol)
630        }
631
632        (Target2::Line(l), Target2::Circle(c)) | (Target2::Circle(c), Target2::Line(l)) => {
633            let n = normal_of(l);
634            let signed = n.dot(c.centre() - l.location);
635            if signed.abs() <= c.radius() + tol.confusion() {
636                ogeom_bail!(
637                    Construction,
638                    "the line meets the circle; the equidistant locus is not one conic"
639                );
640            }
641            // |x − centre| − r = distance to line, on the circle's side:
642            // a parabola with the centre as focus and, as the directrix, the
643            // line shifted r away from the circle.
644            let toward = if signed > 0.0 { n } else { -n };
645            let directrix_foot = l.location + perp_foot_shift(l, c.centre()) - toward * c.radius();
646            let focus = c.centre();
647            let foot_to_focus = focus - directrix_foot;
648            let apex = directrix_foot + foot_to_focus * 0.5;
649            let x = Direction2::new(foot_to_focus, tol)?;
650            Ok(Bisector2::Parabola(Parabola2::new(
651                Frame2::new(apex, x),
652                foot_to_focus.magnitude() / 2.0,
653                tol,
654            )?))
655        }
656
657        (Target2::Circle(c1), Target2::Circle(c2)) => {
658            let spread = c1.centre().distance(c2.centre());
659            if spread <= tol.confusion() {
660                // Concentric: the midway circle.
661                let radius = f64::midpoint(c1.radius(), c2.radius());
662                let circle = Circle2::new(Frame2::new(c1.centre(), Direction2::X), radius, tol)?;
663                let _ = circle;
664                ogeom_bail!(
665                    Construction,
666                    "concentric circles bisect on a circle; ask for it as one"
667                );
668            }
669            if (c1.radius() - c2.radius()).abs() <= tol.confusion() {
670                // Equal radii: the perpendicular bisector of the centres.
671                let mid = c1.centre() + (c2.centre() - c1.centre()) * 0.5;
672                let direction = Direction2::new(perp(c2.centre() - c1.centre()), tol)?;
673                return Ok(Bisector2::Line(Axis2::new(mid, direction)));
674            }
675            // | |x−c1| − |x−c2| | = |r1 − r2|: a hyperbola with the centres
676            // as foci.
677            let difference = (c1.radius() - c2.radius()).abs();
678            if difference >= spread - tol.confusion() {
679                ogeom_bail!(
680                    Construction,
681                    "one circle encloses the other too deeply; the locus degenerates"
682                );
683            }
684            let centre = c1.centre() + (c2.centre() - c1.centre()) * 0.5;
685            // The equidistant branch bends round the smaller circle, and
686            // the frame's `+x` side is the branch reported as the locus.
687            let (larger, smaller) = if c1.radius() > c2.radius() {
688                (c1, c2)
689            } else {
690                (c2, c1)
691            };
692            let x = Direction2::new(smaller.centre() - larger.centre(), tol)?;
693            let a_half = difference / 2.0;
694            let c_half = spread / 2.0;
695            let b_half = (c_half * c_half - a_half * a_half).sqrt();
696            Ok(Bisector2::Hyperbola(Hyperbola2::new(
697                Frame2::new(centre, x),
698                a_half,
699                b_half,
700                tol,
701            )?))
702        }
703    }
704}
705
706/// The conic with foci at `f1`, `f2` where the boundary-distance equality
707/// gives `|x−f1| ± |x−f2| = r`: an ellipse when the point sits inside the
708/// circle, a hyperbola outside.
709fn foci_conic(
710    circle_centre: Point2,
711    point: Point2,
712    r: f64,
713    spread: f64,
714    tol: Tolerances,
715) -> OgeomResult<Bisector2> {
716    let centre = circle_centre + (point - circle_centre) * 0.5;
717    let x = Direction2::new(point - circle_centre, tol)?;
718    let a_half = r / 2.0;
719    let c_half = spread / 2.0;
720    if spread < r {
721        // Inside: |x−centre| + |x−p| = r, an ellipse.
722        let b_half = (a_half * a_half - c_half * c_half).sqrt();
723        Ok(Bisector2::Ellipse(Ellipse2::new(
724            Frame2::new(centre, x),
725            a_half,
726            b_half,
727            tol,
728        )?))
729    } else {
730        // Outside: |x−centre| − |x−p| = ±r, a hyperbola.
731        let b_half = (c_half * c_half - a_half * a_half).sqrt();
732        Ok(Bisector2::Hyperbola(Hyperbola2::new(
733            Frame2::new(centre, x),
734            a_half,
735            b_half,
736            tol,
737        )?))
738    }
739}
740
741fn perp(v: Vector2) -> Vector2 {
742    Vector2::new(-v.y, v.x)
743}
744
745/// The component of `to − axis.location` along the line: the foot offset.
746fn perp_foot_shift(axis: &Axis2, to: Point2) -> Vector2 {
747    let along = axis.direction.vector();
748    along * along.dot(to - axis.location)
749}
750
751fn intersect_lines(n1: Vector2, d1: f64, n2: Vector2, d2: f64) -> OgeomResult<Point2> {
752    let det = n1.x * n2.y - n1.y * n2.x;
753    if det.abs() <= f64::MIN_POSITIVE {
754        ogeom_bail!(Construction, "parallel lines do not meet");
755    }
756    Ok(Point2::new(
757        (d1 * n2.y - d2 * n1.y) / det,
758        (n1.x * d2 - n2.x * d1) / det,
759    ))
760}
761
762#[cfg(test)]
763#[allow(clippy::unwrap_used)]
764mod tests {
765    use super::*;
766
767    const T: Tolerances = Tolerances::millimetres();
768
769    fn circle(x: f64, y: f64, r: f64) -> Circle2 {
770        Circle2::new(Frame2::new(Point2::new(x, y), Direction2::X), r, T).unwrap()
771    }
772
773    /// Every returned circle touches every target, by measurement.
774    fn assert_tangent(solutions: &[TangentCircle], targets: &[Target2; 3]) {
775        assert!(!solutions.is_empty(), "the construction found nothing");
776        for s in solutions {
777            for target in targets {
778                let gap = match target {
779                    Target2::Point(p) => (s.circle.centre().distance(*p) - s.circle.radius()).abs(),
780                    Target2::Line(l) => {
781                        (l.distance_to(s.circle.centre()) - s.circle.radius()).abs()
782                    }
783                    Target2::Circle(c) => {
784                        let d = s.circle.centre().distance(c.centre());
785                        (d - (s.circle.radius() + c.radius()))
786                            .abs()
787                            .min((d - (s.circle.radius() - c.radius()).abs()).abs())
788                    }
789                };
790                assert!(gap < 1e-9, "tangency gap {gap} on {target:?} for {s:?}");
791            }
792        }
793    }
794
795    #[test]
796    fn three_points_give_the_circumcircle() {
797        let targets = [
798            Target2::Point(Point2::new(0.0, 0.0)),
799            Target2::Point(Point2::new(4.0, 0.0)),
800            Target2::Point(Point2::new(0.0, 3.0)),
801        ];
802        let found = circles_tangent_to_three(&targets, T).unwrap();
803        assert_eq!(found.len(), 1);
804        // The 3-4-5 right triangle's circumradius is the hypotenuse over two.
805        assert!((found[0].circle.radius() - 2.5).abs() < 1e-9);
806        assert_tangent(&found, &targets);
807    }
808
809    #[test]
810    fn three_lines_give_the_incircle_and_excircles() {
811        // The 3-4-5 right triangle: incircle radius 1, three excircles.
812        let targets = [
813            Target2::Line(Axis2::new(Point2::new(0.0, 0.0), Direction2::X)),
814            Target2::Line(Axis2::new(Point2::new(0.0, 0.0), Direction2::Y)),
815            Target2::Line(Axis2::new(
816                Point2::new(4.0, 0.0),
817                Direction2::new(Vector2::new(-4.0, 3.0), T).unwrap(),
818            )),
819        ];
820        let found = circles_tangent_to_three(&targets, T).unwrap();
821        assert_eq!(found.len(), 4, "incircle and three excircles: {found:?}");
822        assert!(
823            found.iter().any(|s| (s.circle.radius() - 1.0).abs() < 1e-9),
824            "the incircle of 3-4-5 has radius 1"
825        );
826        assert_tangent(&found, &targets);
827    }
828
829    #[test]
830    fn apollonius_three_circles_yields_eight() {
831        // The classical configuration: three mutually external circles in
832        // general position give all eight Apollonius circles.
833        let targets = [
834            Target2::Circle(circle(0.0, 0.0, 1.0)),
835            Target2::Circle(circle(6.0, 0.0, 1.5)),
836            Target2::Circle(circle(2.5, 5.0, 2.0)),
837        ];
838        let found = circles_tangent_to_three(&targets, T).unwrap();
839        assert_eq!(found.len(), 8, "Apollonius promises eight: {}", found.len());
840        assert_tangent(&found, &targets);
841        // Among them, one touches all three from outside and one encloses
842        // all three.
843        assert!(
844            found
845                .iter()
846                .any(|s| s.placements == [Placement::Outside; 3])
847        );
848        assert!(
849            found
850                .iter()
851                .any(|s| s.placements == [Placement::Enclosing; 3])
852        );
853    }
854
855    #[test]
856    fn mixed_targets_and_fixed_radius_answer() {
857        let targets = [
858            Target2::Point(Point2::new(1.0, 2.0)),
859            Target2::Line(Axis2::new(Point2::new(0.0, -1.0), Direction2::X)),
860            Target2::Circle(circle(5.0, 3.0, 1.0)),
861        ];
862        let found = circles_tangent_to_three(&targets, T).unwrap();
863        assert_tangent(&found, &targets);
864
865        let two = [
866            Target2::Line(Axis2::new(Point2::new(0.0, 0.0), Direction2::X)),
867            Target2::Circle(circle(0.0, 5.0, 1.0)),
868        ];
869        let sized = circles_of_radius_tangent_to_two(2.0, &two, T).unwrap();
870        assert!(!sized.is_empty());
871        for s in &sized {
872            assert!((s.circle.radius() - 2.0).abs() < 1e-9);
873            let d0 = Target2::distance_to(&two[0], s.circle.centre());
874            let d1 = Target2::distance_to(&two[1], s.circle.centre());
875            assert!((d0 - 2.0).abs() < 1e-9 && (d1 - 2.0).abs() < 1e-9, "{s:?}");
876        }
877    }
878
879    #[test]
880    fn bitangent_lines_touch_both_circles() {
881        let a = circle(0.0, 0.0, 2.0);
882        let b = circle(8.0, 0.0, 1.0);
883        let lines = lines_tangent_to_two_circles(&a, &b, T);
884        assert_eq!(lines.len(), 4, "external pair and internal pair");
885        for line in &lines {
886            assert!((line.distance_to(a.centre()) - 2.0).abs() < 1e-9);
887            assert!((line.distance_to(b.centre()) - 1.0).abs() < 1e-9);
888        }
889    }
890
891    #[test]
892    fn touching_circles_have_three_tangent_lines() {
893        // Touching from outside: the external pair and the line through the
894        // touching point; from inside, that line alone.
895        for (b, expected, touch) in [
896            (circle(3.0, 0.0, 2.0), 3, Point2::new(1.0, 0.0)),
897            (circle(1.0, 0.0, 2.0), 1, Point2::new(-1.0, 0.0)),
898        ] {
899            let a = circle(0.0, 0.0, 1.0);
900            let lines = lines_tangent_to_two_circles(&a, &b, T);
901            assert_eq!(lines.len(), expected, "{b:?}");
902            for line in &lines {
903                assert!((line.distance_to(a.centre()) - a.radius()).abs() < 1e-9);
904                assert!((line.distance_to(b.centre()) - b.radius()).abs() < 1e-9);
905            }
906            assert!(lines.iter().any(|line| line.distance_to(touch) < 1e-9));
907        }
908    }
909
910    /// Sample a bisector and assert the defining property: equidistance
911    /// from both targets, measured literally.
912    fn assert_equidistant(bisector: &Bisector2, a: &Target2, b: &Target2) {
913        let probes: Vec<Point2> = match bisector {
914            Bisector2::Line(axis) => (-5..=5)
915                .map(|i| axis.location + axis.direction.vector() * f64::from(i))
916                .collect(),
917            Bisector2::Pair(axes) => axes
918                .iter()
919                .flat_map(|axis| {
920                    (-3..=3).map(move |i| axis.location + axis.direction.vector() * f64::from(i))
921                })
922                .collect(),
923            Bisector2::Parabola(p) => (-5..=5)
924                .map(|i| {
925                    let t = f64::from(i);
926                    let frame = p.frame();
927                    frame.origin()
928                        + frame.x().vector() * (t * t / (4.0 * p.focal()))
929                        + frame.y().vector() * t
930                })
931                .collect(),
932            Bisector2::Ellipse(e) => (0..12)
933                .map(|i| {
934                    let t = core::f64::consts::TAU * f64::from(i) / 12.0;
935                    let frame = e.frame();
936                    frame.origin()
937                        + frame.x().vector() * (e.major_radius() * t.cos())
938                        + frame.y().vector() * (e.minor_radius() * t.sin())
939                })
940                .collect(),
941            // Only the +x branch is the boundary bisector.
942            Bisector2::Hyperbola(h) => (-3..=3)
943                .map(|i| {
944                    let t = 0.6 * f64::from(i);
945                    let frame = h.frame();
946                    frame.origin()
947                        + frame.x().vector() * (h.major_radius() * t.cosh())
948                        + frame.y().vector() * (h.minor_radius() * t.sinh())
949                })
950                .collect(),
951        };
952        for p in probes {
953            let (da, db) = (a.distance_to(p), b.distance_to(p));
954            // A hyperbola carries both branches. Each point serves one side.
955            assert!(
956                (da - db).abs() < 1e-9,
957                "not equidistant at {p:?}: {da} vs {db} for {bisector:?}"
958            );
959        }
960    }
961
962    #[test]
963    fn bisectors_are_equidistant_loci() {
964        let point = Target2::Point(Point2::new(1.0, 1.0));
965        let other = Target2::Point(Point2::new(-1.0, 2.0));
966        let line = Target2::Line(Axis2::new(Point2::new(0.0, -2.0), Direction2::X));
967        let small = Target2::Circle(circle(0.0, 0.0, 5.0));
968        let far = Target2::Circle(circle(12.0, 0.0, 2.0));
969
970        assert_equidistant(&bisector(&point, &other, T).unwrap(), &point, &other);
971        assert_equidistant(&bisector(&point, &line, T).unwrap(), &point, &line);
972        // Point inside the circle: an ellipse.
973        let inside = bisector(&point, &small, T).unwrap();
974        assert!(matches!(inside, Bisector2::Ellipse(_)), "{inside:?}");
975        assert_equidistant(&inside, &point, &small);
976        // Unequal circles: a hyperbola.
977        let between = bisector(&small, &far, T).unwrap();
978        assert!(matches!(between, Bisector2::Hyperbola(_)), "{between:?}");
979        assert_equidistant(&between, &small, &far);
980        // Asked the other way round, the smaller circle first: the same
981        // locus, round the smaller circle still.
982        let between = bisector(&far, &small, T).unwrap();
983        assert_equidistant(&between, &far, &small);
984        // Intersecting lines: the two angle bisectors.
985        let slanted = Target2::Line(Axis2::new(
986            Point2::new(0.0, -2.0),
987            Direction2::new(Vector2::new(1.0, 1.0), T).unwrap(),
988        ));
989        let pair = bisector(&line, &slanted, T).unwrap();
990        assert!(matches!(pair, Bisector2::Pair(_)), "{pair:?}");
991        assert_equidistant(&pair, &line, &slanted);
992    }
993
994    /// A micron-sized circle through three points ten metres from the
995    /// origin comes back to rounding, as it does at the origin.
996    #[test]
997    fn tangent_circles_far_from_the_origin_keep_their_digits() {
998        for offset in [0.0, 1_000.0, 10_000.0] {
999            let radius: f64 = 1e-3;
1000            let through = [0.3_f64, 2.0, 4.1].map(|a| {
1001                Target2::Point(Point2::new(
1002                    radius.mul_add(a.cos(), offset),
1003                    radius.mul_add(a.sin(), offset),
1004                ))
1005            });
1006            let found = circles_tangent_to_three(&through, T).unwrap();
1007            assert_eq!(found.len(), 1, "{offset}");
1008            let circle = found[0].circle;
1009            assert!((circle.radius() - radius).abs() < 1e-12, "{offset}");
1010            assert!(circle.centre().distance(Point2::new(offset, offset)) < 1e-12);
1011        }
1012    }
1013}