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    for i in 0..3 {
132        for j in i + 1..3 {
133            if targets_coincide(&targets[i], &targets[j], tol) {
134                ogeom_bail!(
135                    Construction,
136                    "targets {i} and {j} coincide; the tangency family is underdetermined"
137                );
138            }
139        }
140    }
141    let mut out: Vec<TangentCircle> = Vec::new();
142    for &s0 in sides_of(&targets[0]) {
143        for &s1 in sides_of(&targets[1]) {
144            for &s2 in sides_of(&targets[2]) {
145                let rows = [
146                    rows_for(&targets[0], s0),
147                    rows_for(&targets[1], s1),
148                    rows_for(&targets[2], s2),
149                ];
150                for candidate in solve_rows(&rows, tol) {
151                    admit(&mut out, candidate, targets, tol);
152                }
153            }
154        }
155    }
156    Ok(out)
157}
158
159/// Circles of a fixed radius tangent to two targets: the same machinery
160/// with the radius row supplied.
161///
162/// # Errors
163///
164/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
165/// radius is not finite and positive, or the targets coincide.
166pub fn circles_of_radius_tangent_to_two(
167    radius: f64,
168    targets: &[Target2; 2],
169    tol: Tolerances,
170) -> OgeomResult<Vec<TangentCircle>> {
171    if !radius.is_finite() || radius <= tol.confusion() {
172        ogeom_bail!(
173            Construction,
174            "a tangent circle of radius {radius} is not a circle"
175        );
176    }
177    if targets_coincide(&targets[0], &targets[1], tol) {
178        ogeom_bail!(Construction, "the two targets coincide");
179    }
180    let mut out: Vec<TangentCircle> = Vec::new();
181    let radius_row: Row = ([0.0, 0.0, 1.0, 0.0], radius);
182    for &s0 in sides_of(&targets[0]) {
183        for &s1 in sides_of(&targets[1]) {
184            let rows = [
185                rows_for(&targets[0], s0),
186                rows_for(&targets[1], s1),
187                radius_row,
188            ];
189            for candidate in solve_rows(&rows, tol) {
190                let three = [targets[0], targets[1], targets[1]];
191                let mut kept = out.clone();
192                admit(&mut kept, candidate, &three, tol);
193                // Re-derive placements for the two real targets only.
194                if kept.len() > out.len() {
195                    let solution = kept[kept.len() - 1].circle;
196                    let placements = [
197                        placement_of(&solution, &targets[0], tol),
198                        placement_of(&solution, &targets[1], tol),
199                        placement_of(&solution, &targets[1], tol),
200                    ];
201                    out.push(TangentCircle {
202                        circle: solution,
203                        placements,
204                    });
205                }
206            }
207        }
208    }
209    Ok(out)
210}
211
212/// The up-to-four lines tangent to two circles: the external pair where the
213/// circles lie on the same side, the internal pair where they straddle.
214#[must_use]
215pub fn lines_tangent_to_two_circles(a: &Circle2, b: &Circle2, tol: Tolerances) -> Vec<Axis2> {
216    let e = b.centre() - a.centre();
217    let distance = e.magnitude();
218    if distance <= tol.confusion() {
219        return Vec::new();
220    }
221    let along = e / distance;
222    let across = Vector2::new(-along.y, along.x);
223    let mut out = Vec::new();
224    // Unit normal n with n·(ca − cb) = s_a·ra − s_b·rb, d = n·ca − s_a·ra.
225    for (sa, sb) in [(1.0, 1.0), (1.0, -1.0)] {
226        let k = (sa * a.radius() - sb * b.radius()) / distance;
227        if k.abs() > 1.0 - tol.angular() {
228            continue;
229        }
230        let across_part = (1.0 - k * k).sqrt();
231        for flip in [1.0, -1.0] {
232            let n = along * -k + across * (across_part * flip);
233            let d = n.dot(a.centre().to_vector()) - sa * a.radius();
234            // The line's own frame: direction perpendicular to n, located at
235            // the foot nearest the midpoint of the centres.
236            let mid = a.centre() + e * 0.5;
237            let foot = mid - n * (n.dot(mid.to_vector()) - d);
238            if let Ok(direction) = Direction2::new(Vector2::new(n.y, -n.x), tol) {
239                out.push(Axis2::new(foot, direction));
240            }
241        }
242    }
243    out
244}
245
246/// Solve three rows for `(c, r)` candidates: direct where `Q` is absent,
247/// the line-family-plus-quadratic where it is present.
248fn solve_rows(rows: &[Row; 3], tol: Tolerances) -> Vec<(Point2, f64)> {
249    let uses_q = rows.iter().any(|(coeffs, _)| coeffs[3] != 0.0);
250    if uses_q {
251        solve_with_q(rows, tol)
252    } else {
253        solve_linear(rows, tol)
254    }
255}
256
257/// All-lines: three equations in `(cx, cy, r)`.
258fn solve_linear(rows: &[Row; 3], _tol: Tolerances) -> Vec<(Point2, f64)> {
259    let m = nalgebra::Matrix3::new(
260        rows[0].0[0],
261        rows[0].0[1],
262        rows[0].0[2],
263        rows[1].0[0],
264        rows[1].0[1],
265        rows[1].0[2],
266        rows[2].0[0],
267        rows[2].0[1],
268        rows[2].0[2],
269    );
270    let b = nalgebra::Vector3::new(rows[0].1, rows[1].1, rows[2].1);
271    let Some(solution) = m.lu().solve(&b) else {
272        return Vec::new();
273    };
274    vec![(Point2::new(solution[0], solution[1]), solution[2])]
275}
276
277/// With `Q` present: the 3×4 system's solution line, cut by the quadratic
278/// `|c|² − r² − Q = 0`. The null vector comes from the four 3×3 minors
279/// (the generalized cross product) and the particular solution from the
280/// best-conditioned 3×3 subsystem with the remaining unknown pinned to
281/// zero.
282fn solve_with_q(rows: &[Row; 3], tol: Tolerances) -> Vec<(Point2, f64)> {
283    let m = [rows[0].0, rows[1].0, rows[2].0];
284    let b = [rows[0].1, rows[1].1, rows[2].1];
285
286    // Minor j: the determinant with column j removed, alternating sign.
287    let minor = |skip: usize| -> f64 {
288        let cols: Vec<usize> = (0..4).filter(|c| *c != skip).collect();
289
290        nalgebra::Matrix3::new(
291            m[0][cols[0]],
292            m[0][cols[1]],
293            m[0][cols[2]],
294            m[1][cols[0]],
295            m[1][cols[1]],
296            m[1][cols[2]],
297            m[2][cols[0]],
298            m[2][cols[1]],
299            m[2][cols[2]],
300        )
301        .determinant()
302    };
303    let null: [f64; 4] = [minor(0), -minor(1), minor(2), -minor(3)];
304    let biggest = null.iter().fold(0.0_f64, |a, v| a.max(v.abs()));
305    if biggest <= 1e-12 {
306        // Rank below three: a degenerate side pattern.
307        return Vec::new();
308    }
309
310    // Particular solution: pin the unknown whose removal leaves the
311    // best-conditioned square system.
312    let pin = (0..4)
313        .max_by(|a, b| {
314            minor(*a)
315                .abs()
316                .partial_cmp(&minor(*b).abs())
317                .unwrap_or(core::cmp::Ordering::Equal)
318        })
319        .unwrap_or(3);
320    let cols: Vec<usize> = (0..4).filter(|c| *c != pin).collect();
321    let square = nalgebra::Matrix3::new(
322        m[0][cols[0]],
323        m[0][cols[1]],
324        m[0][cols[2]],
325        m[1][cols[0]],
326        m[1][cols[1]],
327        m[1][cols[2]],
328        m[2][cols[0]],
329        m[2][cols[1]],
330        m[2][cols[2]],
331    );
332    let rhs = nalgebra::Vector3::new(b[0], b[1], b[2]);
333    let Some(solved) = square.lu().solve(&rhs) else {
334        return Vec::new();
335    };
336    let mut particular = [0.0f64; 4];
337    for (slot, col) in cols.iter().enumerate() {
338        particular[*col] = solved[slot];
339    }
340
341    // g(λ) = |c|² − r² − Q along x = particular + λ·null.
342    let (px, py, pr, pq) = (particular[0], particular[1], particular[2], particular[3]);
343    let (nx, ny, nr, nq) = (null[0], null[1], null[2], null[3]);
344    let a2 = nx * nx + ny * ny - nr * nr;
345    let a1 = 2.0 * (px * nx + py * ny - pr * nr) - nq;
346    let a0 = px * px + py * py - pr * pr - pq;
347
348    let mut lambdas = Vec::new();
349    if a2.abs() <= 1e-14 * (a1.abs().max(a0.abs()).max(1.0)) {
350        if a1.abs() > 1e-14 {
351            lambdas.push(-a0 / a1);
352        }
353    } else {
354        // The quadratic's vertex is always a candidate: a tangent
355        // configuration's double root sits exactly there, and rounding
356        // renders its discriminant a hair negative. The literal tangency
357        // verification downstream rejects the vertex whenever it is not a
358        // real solution, so offering it costs nothing and loses nothing.
359        lambdas.push(-a1 / (2.0 * a2));
360        let disc = a1.mul_add(a1, -4.0 * a2 * a0);
361        if disc > 0.0 {
362            let root = disc.sqrt();
363            lambdas.push((-a1 + root) / (2.0 * a2));
364            lambdas.push((-a1 - root) / (2.0 * a2));
365        }
366    }
367    lambdas
368        .into_iter()
369        .map(|l| (Point2::new(px + l * nx, py + l * ny), pr + l * nr))
370        .filter(|(_, r)| r.is_finite() && *r > tol.confusion())
371        .collect()
372}
373
374/// Verify a candidate against the literal tangency distances and admit it
375/// once.
376fn admit(
377    out: &mut Vec<TangentCircle>,
378    (centre, radius): (Point2, f64),
379    targets: &[Target2; 3],
380    tol: Tolerances,
381) {
382    let slack = tol.confusion() * 1e3 * radius.max(1.0);
383    for target in targets {
384        let touch = match target {
385            Target2::Point(p) => (centre.distance(*p) - radius).abs(),
386            Target2::Line(axis) => (axis.distance_to(centre) - radius).abs(),
387            Target2::Circle(c) => {
388                let d = centre.distance(c.centre());
389                (d - (radius + c.radius()))
390                    .abs()
391                    .min((d - (radius - c.radius()).abs()).abs())
392            }
393        };
394        if touch > slack {
395            return;
396        }
397    }
398    if out.iter().any(|held| {
399        held.circle.centre().distance(centre) <= slack
400            && (held.circle.radius() - radius).abs() <= slack
401    }) {
402        return;
403    }
404    let Ok(circle) = Circle2::new(Frame2::new(centre, Direction2::X), radius, tol) else {
405        return;
406    };
407    let placements = [
408        placement_of(&circle, &targets[0], tol),
409        placement_of(&circle, &targets[1], tol),
410        placement_of(&circle, &targets[2], tol),
411    ];
412    out.push(TangentCircle { circle, placements });
413}
414
415fn placement_of(circle: &Circle2, target: &Target2, tol: Tolerances) -> Placement {
416    match target {
417        Target2::Point(_) => Placement::Through,
418        Target2::Line(_) => Placement::Tangent,
419        Target2::Circle(c) => {
420            let d = circle.centre().distance(c.centre());
421            let slack = tol.confusion() * 1e3 * circle.radius().max(1.0);
422            if (d - (circle.radius() + c.radius())).abs() <= slack {
423                Placement::Outside
424            } else if circle.radius() >= c.radius()
425                && (d - (circle.radius() - c.radius())).abs() <= slack
426            {
427                Placement::Enclosing
428            } else {
429                Placement::Enclosed
430            }
431        }
432    }
433}
434
435fn targets_coincide(a: &Target2, b: &Target2, tol: Tolerances) -> bool {
436    match (a, b) {
437        (Target2::Point(p), Target2::Point(q)) => p.is_equal(*q, tol),
438        (Target2::Circle(c), Target2::Circle(d)) => {
439            c.centre().is_equal(d.centre(), tol)
440                && (c.radius() - d.radius()).abs() <= tol.confusion()
441        }
442        (Target2::Line(a), Target2::Line(b)) => {
443            let na = normal_of(a);
444            let nb = normal_of(b);
445            na.cross(nb).abs() <= tol.angular() && a.distance_to(b.location) <= tol.confusion()
446        }
447        _ => false,
448    }
449}
450
451// --- bisectors ---------------------------------------------------------------
452
453/// The locus of points equidistant from two targets.
454#[derive(Debug, Clone, Copy, PartialEq)]
455pub enum Bisector2 {
456    /// A single line: two points, or two parallel lines.
457    Line(Axis2),
458    /// The two angle bisectors of intersecting lines.
459    Pair([Axis2; 2]),
460    /// Point against line, or line against circle: a parabola.
461    Parabola(Parabola2),
462    /// A point inside a circle, or nested circles: an ellipse.
463    Ellipse(Ellipse2),
464    /// A point outside a circle, or circles of unequal radius: a hyperbola.
465    /// The equidistant locus is the branch on the frame's `+x` side, toward
466    /// the point, or toward the smaller circle; the mirror branch comes with
467    /// the conic but bisects nothing.
468    Hyperbola(Hyperbola2),
469}
470
471/// The bisector of two targets: the equidistant locus, as the conic it is.
472///
473/// For circle targets the distance is to the *boundary*, which is what makes
474/// the answer a conic with the centres as foci.
475///
476/// # Errors
477///
478/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
479/// targets coincide, or a point lies on a line or circle target; the locus
480/// then degenerates to something that is not a curve.
481pub fn bisector(a: &Target2, b: &Target2, tol: Tolerances) -> OgeomResult<Bisector2> {
482    if targets_coincide(a, b, tol) {
483        ogeom_bail!(Construction, "coincident targets bisect everywhere");
484    }
485    // Normalize the order so each pair is handled once.
486    match (a, b) {
487        (Target2::Point(p), Target2::Point(q)) => {
488            let mid = *p + (*q - *p) * 0.5;
489            let direction = Direction2::new(perp(*q - *p), tol)?;
490            Ok(Bisector2::Line(Axis2::new(mid, direction)))
491        }
492
493        (Target2::Line(l), Target2::Line(m)) => {
494            let nl = normal_of(l);
495            let nm = normal_of(m);
496            let dl = nl.dot(l.location.to_vector());
497            let dm = nm.dot(m.location.to_vector());
498            if nl.cross(nm).abs() <= tol.angular() {
499                // Parallel: the midline. Align the normals first.
500                let (nm, dm) = if nl.dot(nm) < 0.0 {
501                    (-nm, -dm)
502                } else {
503                    (nm, dm)
504                };
505                let _ = nm;
506                let offset = f64::midpoint(dl, dm);
507                let foot = Point2::new(nl.x * offset, nl.y * offset);
508                return Ok(Bisector2::Line(Axis2::new(foot, l.direction)));
509            }
510            // Intersecting: n_l·x − d_l = ±(n_m·x − d_m).
511            let apex = intersect_lines(nl, dl, nm, dm)?;
512            let d1 = Direction2::new(l.direction.vector() + m.direction.vector(), tol)
513                .or_else(|_| Direction2::new(perp(l.direction.vector()), tol))?;
514            let d2 = Direction2::new(perp(d1.vector()), tol)?;
515            Ok(Bisector2::Pair([
516                Axis2::new(apex, d1),
517                Axis2::new(apex, d2),
518            ]))
519        }
520
521        (Target2::Point(p), Target2::Line(l)) | (Target2::Line(l), Target2::Point(p)) => {
522            let n = normal_of(l);
523            let signed = n.dot(*p - l.location);
524            if signed.abs() <= tol.confusion() {
525                ogeom_bail!(
526                    Construction,
527                    "the point lies on the line; the locus degenerates"
528                );
529            }
530            // Focus at the point, directrix the line: apex midway, opening
531            // from the line toward the point.
532            let foot = *p - n * signed;
533            let apex = foot + (*p - foot) * 0.5;
534            let x = Direction2::new(*p - foot, tol)?;
535            let frame = Frame2::new(apex, x);
536            Ok(Bisector2::Parabola(Parabola2::new(
537                frame,
538                signed.abs() / 2.0,
539                tol,
540            )?))
541        }
542
543        (Target2::Point(p), Target2::Circle(c)) | (Target2::Circle(c), Target2::Point(p)) => {
544            let spread = p.distance(c.centre());
545            let r = c.radius();
546            if (spread - r).abs() <= tol.confusion() {
547                ogeom_bail!(
548                    Construction,
549                    "the point lies on the circle; the locus degenerates"
550                );
551            }
552            foci_conic(c.centre(), *p, r, spread, tol)
553        }
554
555        (Target2::Line(l), Target2::Circle(c)) | (Target2::Circle(c), Target2::Line(l)) => {
556            let n = normal_of(l);
557            let signed = n.dot(c.centre() - l.location);
558            if signed.abs() <= c.radius() + tol.confusion() {
559                ogeom_bail!(
560                    Construction,
561                    "the line meets the circle; the equidistant locus is not one conic"
562                );
563            }
564            // |x − centre| − r = distance to line, on the circle's side:
565            // a parabola with the centre as focus and the line shifted r
566            // toward the circle... away from it, as the directrix.
567            let toward = if signed > 0.0 { n } else { -n };
568            let directrix_foot = l.location + perp_foot_shift(l, c.centre()) - toward * c.radius();
569            let focus = c.centre();
570            let foot_to_focus = focus - directrix_foot;
571            let apex = directrix_foot + foot_to_focus * 0.5;
572            let x = Direction2::new(foot_to_focus, tol)?;
573            Ok(Bisector2::Parabola(Parabola2::new(
574                Frame2::new(apex, x),
575                foot_to_focus.magnitude() / 2.0,
576                tol,
577            )?))
578        }
579
580        (Target2::Circle(c1), Target2::Circle(c2)) => {
581            let spread = c1.centre().distance(c2.centre());
582            if spread <= tol.confusion() {
583                // Concentric: the midway circle.
584                let radius = f64::midpoint(c1.radius(), c2.radius());
585                let circle = Circle2::new(Frame2::new(c1.centre(), Direction2::X), radius, tol)?;
586                let _ = circle;
587                ogeom_bail!(
588                    Construction,
589                    "concentric circles bisect on a circle; ask for it as one"
590                );
591            }
592            if (c1.radius() - c2.radius()).abs() <= tol.confusion() {
593                // Equal radii: the perpendicular bisector of the centres.
594                let mid = c1.centre() + (c2.centre() - c1.centre()) * 0.5;
595                let direction = Direction2::new(perp(c2.centre() - c1.centre()), tol)?;
596                return Ok(Bisector2::Line(Axis2::new(mid, direction)));
597            }
598            // | |x−c1| − |x−c2| | = |r1 − r2|: a hyperbola with the centres
599            // as foci.
600            let difference = (c1.radius() - c2.radius()).abs();
601            if difference >= spread - tol.confusion() {
602                ogeom_bail!(
603                    Construction,
604                    "one circle encloses the other too deeply; the locus degenerates"
605                );
606            }
607            let centre = c1.centre() + (c2.centre() - c1.centre()) * 0.5;
608            let x = Direction2::new(c2.centre() - c1.centre(), tol)?;
609            let a_half = difference / 2.0;
610            let c_half = spread / 2.0;
611            let b_half = (c_half * c_half - a_half * a_half).sqrt();
612            Ok(Bisector2::Hyperbola(Hyperbola2::new(
613                Frame2::new(centre, x),
614                a_half,
615                b_half,
616                tol,
617            )?))
618        }
619    }
620}
621
622/// The conic with foci at `f1`, `f2` where the boundary-distance equality
623/// gives `|x−f1| ± |x−f2| = r`: an ellipse when the point sits inside the
624/// circle, a hyperbola outside.
625fn foci_conic(
626    circle_centre: Point2,
627    point: Point2,
628    r: f64,
629    spread: f64,
630    tol: Tolerances,
631) -> OgeomResult<Bisector2> {
632    let centre = circle_centre + (point - circle_centre) * 0.5;
633    let x = Direction2::new(point - circle_centre, tol)?;
634    let a_half = r / 2.0;
635    let c_half = spread / 2.0;
636    if spread < r {
637        // Inside: |x−centre| + |x−p| = r, an ellipse.
638        let b_half = (a_half * a_half - c_half * c_half).sqrt();
639        Ok(Bisector2::Ellipse(Ellipse2::new(
640            Frame2::new(centre, x),
641            a_half,
642            b_half,
643            tol,
644        )?))
645    } else {
646        // Outside: |x−centre| − |x−p| = ±r, a hyperbola.
647        let b_half = (c_half * c_half - a_half * a_half).sqrt();
648        Ok(Bisector2::Hyperbola(Hyperbola2::new(
649            Frame2::new(centre, x),
650            a_half,
651            b_half,
652            tol,
653        )?))
654    }
655}
656
657fn perp(v: Vector2) -> Vector2 {
658    Vector2::new(-v.y, v.x)
659}
660
661/// The component of `to − axis.location` along the line: the foot offset.
662fn perp_foot_shift(axis: &Axis2, to: Point2) -> Vector2 {
663    let along = axis.direction.vector();
664    along * along.dot(to - axis.location)
665}
666
667fn intersect_lines(n1: Vector2, d1: f64, n2: Vector2, d2: f64) -> OgeomResult<Point2> {
668    let det = n1.x * n2.y - n1.y * n2.x;
669    if det.abs() <= f64::MIN_POSITIVE {
670        ogeom_bail!(Construction, "parallel lines do not meet");
671    }
672    Ok(Point2::new(
673        (d1 * n2.y - d2 * n1.y) / det,
674        (n1.x * d2 - n2.x * d1) / det,
675    ))
676}
677
678#[cfg(test)]
679#[allow(clippy::unwrap_used)]
680mod tests {
681    use super::*;
682
683    const T: Tolerances = Tolerances::millimetres();
684
685    fn circle(x: f64, y: f64, r: f64) -> Circle2 {
686        Circle2::new(Frame2::new(Point2::new(x, y), Direction2::X), r, T).unwrap()
687    }
688
689    /// Every returned circle touches every target, by measurement.
690    fn assert_tangent(solutions: &[TangentCircle], targets: &[Target2; 3]) {
691        assert!(!solutions.is_empty(), "the construction found nothing");
692        for s in solutions {
693            for target in targets {
694                let gap = match target {
695                    Target2::Point(p) => (s.circle.centre().distance(*p) - s.circle.radius()).abs(),
696                    Target2::Line(l) => {
697                        (l.distance_to(s.circle.centre()) - s.circle.radius()).abs()
698                    }
699                    Target2::Circle(c) => {
700                        let d = s.circle.centre().distance(c.centre());
701                        (d - (s.circle.radius() + c.radius()))
702                            .abs()
703                            .min((d - (s.circle.radius() - c.radius()).abs()).abs())
704                    }
705                };
706                assert!(gap < 1e-9, "tangency gap {gap} on {target:?} for {s:?}");
707            }
708        }
709    }
710
711    #[test]
712    fn three_points_give_the_circumcircle() {
713        let targets = [
714            Target2::Point(Point2::new(0.0, 0.0)),
715            Target2::Point(Point2::new(4.0, 0.0)),
716            Target2::Point(Point2::new(0.0, 3.0)),
717        ];
718        let found = circles_tangent_to_three(&targets, T).unwrap();
719        assert_eq!(found.len(), 1);
720        // The 3-4-5 right triangle's circumradius is the hypotenuse over two.
721        assert!((found[0].circle.radius() - 2.5).abs() < 1e-9);
722        assert_tangent(&found, &targets);
723    }
724
725    #[test]
726    fn three_lines_give_the_incircle_and_excircles() {
727        // The 3-4-5 right triangle: incircle radius 1, three excircles.
728        let targets = [
729            Target2::Line(Axis2::new(Point2::new(0.0, 0.0), Direction2::X)),
730            Target2::Line(Axis2::new(Point2::new(0.0, 0.0), Direction2::Y)),
731            Target2::Line(Axis2::new(
732                Point2::new(4.0, 0.0),
733                Direction2::new(Vector2::new(-4.0, 3.0), T).unwrap(),
734            )),
735        ];
736        let found = circles_tangent_to_three(&targets, T).unwrap();
737        assert_eq!(found.len(), 4, "incircle and three excircles: {found:?}");
738        assert!(
739            found.iter().any(|s| (s.circle.radius() - 1.0).abs() < 1e-9),
740            "the incircle of 3-4-5 has radius 1"
741        );
742        assert_tangent(&found, &targets);
743    }
744
745    #[test]
746    fn apollonius_three_circles_yields_eight() {
747        // The classical configuration: three mutually external circles in
748        // general position give all eight Apollonius circles.
749        let targets = [
750            Target2::Circle(circle(0.0, 0.0, 1.0)),
751            Target2::Circle(circle(6.0, 0.0, 1.5)),
752            Target2::Circle(circle(2.5, 5.0, 2.0)),
753        ];
754        let found = circles_tangent_to_three(&targets, T).unwrap();
755        assert_eq!(found.len(), 8, "Apollonius promises eight: {}", found.len());
756        assert_tangent(&found, &targets);
757        // Among them, one touches all three from outside and one encloses
758        // all three.
759        assert!(
760            found
761                .iter()
762                .any(|s| s.placements == [Placement::Outside; 3])
763        );
764        assert!(
765            found
766                .iter()
767                .any(|s| s.placements == [Placement::Enclosing; 3])
768        );
769    }
770
771    #[test]
772    fn mixed_targets_and_fixed_radius_answer() {
773        let targets = [
774            Target2::Point(Point2::new(1.0, 2.0)),
775            Target2::Line(Axis2::new(Point2::new(0.0, -1.0), Direction2::X)),
776            Target2::Circle(circle(5.0, 3.0, 1.0)),
777        ];
778        let found = circles_tangent_to_three(&targets, T).unwrap();
779        assert_tangent(&found, &targets);
780
781        let two = [
782            Target2::Line(Axis2::new(Point2::new(0.0, 0.0), Direction2::X)),
783            Target2::Circle(circle(0.0, 5.0, 1.0)),
784        ];
785        let sized = circles_of_radius_tangent_to_two(2.0, &two, T).unwrap();
786        assert!(!sized.is_empty());
787        for s in &sized {
788            assert!((s.circle.radius() - 2.0).abs() < 1e-9);
789            let d0 = Target2::distance_to(&two[0], s.circle.centre());
790            let d1 = Target2::distance_to(&two[1], s.circle.centre());
791            assert!((d0 - 2.0).abs() < 1e-9 && (d1 - 2.0).abs() < 1e-9, "{s:?}");
792        }
793    }
794
795    #[test]
796    fn bitangent_lines_touch_both_circles() {
797        let a = circle(0.0, 0.0, 2.0);
798        let b = circle(8.0, 0.0, 1.0);
799        let lines = lines_tangent_to_two_circles(&a, &b, T);
800        assert_eq!(lines.len(), 4, "external pair and internal pair");
801        for line in &lines {
802            assert!((line.distance_to(a.centre()) - 2.0).abs() < 1e-9);
803            assert!((line.distance_to(b.centre()) - 1.0).abs() < 1e-9);
804        }
805    }
806
807    /// Sample a bisector and assert the defining property: equidistance
808    /// from both targets, measured literally.
809    fn assert_equidistant(bisector: &Bisector2, a: &Target2, b: &Target2) {
810        let probes: Vec<Point2> = match bisector {
811            Bisector2::Line(axis) => (-5..=5)
812                .map(|i| axis.location + axis.direction.vector() * f64::from(i))
813                .collect(),
814            Bisector2::Pair(axes) => axes
815                .iter()
816                .flat_map(|axis| {
817                    (-3..=3).map(move |i| axis.location + axis.direction.vector() * f64::from(i))
818                })
819                .collect(),
820            Bisector2::Parabola(p) => (-5..=5)
821                .map(|i| {
822                    let t = f64::from(i);
823                    let frame = p.frame();
824                    frame.origin()
825                        + frame.x().vector() * (t * t / (4.0 * p.focal()))
826                        + frame.y().vector() * t
827                })
828                .collect(),
829            Bisector2::Ellipse(e) => (0..12)
830                .map(|i| {
831                    let t = core::f64::consts::TAU * f64::from(i) / 12.0;
832                    let frame = e.frame();
833                    frame.origin()
834                        + frame.x().vector() * (e.major_radius() * t.cos())
835                        + frame.y().vector() * (e.minor_radius() * t.sin())
836                })
837                .collect(),
838            // Only the +x branch is the boundary bisector.
839            Bisector2::Hyperbola(h) => (-3..=3)
840                .map(|i| {
841                    let t = 0.6 * f64::from(i);
842                    let frame = h.frame();
843                    frame.origin()
844                        + frame.x().vector() * (h.major_radius() * t.cosh())
845                        + frame.y().vector() * (h.minor_radius() * t.sinh())
846                })
847                .collect(),
848        };
849        for p in probes {
850            let (da, db) = (a.distance_to(p), b.distance_to(p));
851            // A hyperbola carries both branches; each point serves one side.
852            assert!(
853                (da - db).abs() < 1e-9,
854                "not equidistant at {p:?}: {da} vs {db} for {bisector:?}"
855            );
856        }
857    }
858
859    #[test]
860    fn bisectors_are_equidistant_loci() {
861        let point = Target2::Point(Point2::new(1.0, 1.0));
862        let other = Target2::Point(Point2::new(-1.0, 2.0));
863        let line = Target2::Line(Axis2::new(Point2::new(0.0, -2.0), Direction2::X));
864        let small = Target2::Circle(circle(0.0, 0.0, 5.0));
865        let far = Target2::Circle(circle(12.0, 0.0, 2.0));
866
867        assert_equidistant(&bisector(&point, &other, T).unwrap(), &point, &other);
868        assert_equidistant(&bisector(&point, &line, T).unwrap(), &point, &line);
869        // Point inside the circle: an ellipse.
870        let inside = bisector(&point, &small, T).unwrap();
871        assert!(matches!(inside, Bisector2::Ellipse(_)), "{inside:?}");
872        assert_equidistant(&inside, &point, &small);
873        // Unequal circles: a hyperbola.
874        let between = bisector(&small, &far, T).unwrap();
875        assert!(matches!(between, Bisector2::Hyperbola(_)), "{between:?}");
876        assert_equidistant(&between, &small, &far);
877        // Intersecting lines: the two angle bisectors.
878        let slanted = Target2::Line(Axis2::new(
879            Point2::new(0.0, -2.0),
880            Direction2::new(Vector2::new(1.0, 1.0), T).unwrap(),
881        ));
882        let pair = bisector(&line, &slanted, T).unwrap();
883        assert!(matches!(pair, Bisector2::Pair(_)), "{pair:?}");
884        assert_equidistant(&pair, &line, &slanted);
885    }
886}