use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
use crate::conic::{Circle2, Ellipse2, Hyperbola2, Parabola2};
use crate::direction::Direction2;
use crate::frame::{Axis2, Frame2};
use crate::point::Point2;
use crate::vector::Vector2;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Target2 {
Point(Point2),
Line(Axis2),
Circle(Circle2),
}
impl Target2 {
#[must_use]
pub fn distance_to(&self, p: Point2) -> f64 {
match self {
Self::Point(q) => p.distance(*q),
Self::Line(axis) => axis.distance_to(p),
Self::Circle(c) => (p.distance(c.centre()) - c.radius()).abs(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Placement {
Through,
Tangent,
Outside,
Enclosing,
Enclosed,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TangentCircle {
pub circle: Circle2,
pub placements: [Placement; 3],
}
type Row = ([f64; 4], f64);
fn rows_for(target: &Target2, side: f64) -> Row {
match target {
Target2::Point(p) => {
([-2.0 * p.x, -2.0 * p.y, 0.0, 1.0], -(p.x * p.x + p.y * p.y))
}
Target2::Circle(c) => {
let centre = c.centre();
let r = c.radius();
(
[-2.0 * centre.x, -2.0 * centre.y, -2.0 * side * r, 1.0],
r * r - (centre.x * centre.x + centre.y * centre.y),
)
}
Target2::Line(axis) => {
let n = normal_of(axis);
let d = n.dot(axis.location.to_vector());
([n.x, n.y, -side, 0.0], d)
}
}
}
fn normal_of(axis: &Axis2) -> Vector2 {
let d = axis.direction.vector();
Vector2::new(-d.y, d.x)
}
fn sides_of(target: &Target2) -> &'static [f64] {
match target {
Target2::Point(_) => &[1.0],
_ => &[1.0, -1.0],
}
}
pub fn circles_tangent_to_three(
targets: &[Target2; 3],
tol: Tolerances,
) -> OgeomResult<Vec<TangentCircle>> {
let shift = middle(targets);
let near = [
shifted(&targets[0], -shift, tol)?,
shifted(&targets[1], -shift, tol)?,
shifted(&targets[2], -shift, tol)?,
];
let found = tangent_to_three_near(&near, tol)?;
found.into_iter().map(|t| moved(t, shift, tol)).collect()
}
fn tangent_to_three_near(
targets: &[Target2; 3],
tol: Tolerances,
) -> OgeomResult<Vec<TangentCircle>> {
for i in 0..3 {
for j in i + 1..3 {
if targets_coincide(&targets[i], &targets[j], tol) {
ogeom_bail!(
Construction,
"targets {i} and {j} coincide; the tangency family is underdetermined"
);
}
}
}
let mut out: Vec<TangentCircle> = Vec::new();
for &s0 in sides_of(&targets[0]) {
for &s1 in sides_of(&targets[1]) {
for &s2 in sides_of(&targets[2]) {
let rows = [
rows_for(&targets[0], s0),
rows_for(&targets[1], s1),
rows_for(&targets[2], s2),
];
for candidate in solve_rows(&rows, tol) {
admit(&mut out, candidate, targets, tol);
}
}
}
}
Ok(out)
}
pub fn circles_of_radius_tangent_to_two(
radius: f64,
targets: &[Target2; 2],
tol: Tolerances,
) -> OgeomResult<Vec<TangentCircle>> {
let shift = middle(targets);
let near = [
shifted(&targets[0], -shift, tol)?,
shifted(&targets[1], -shift, tol)?,
];
let found = of_radius_near(radius, &near, tol)?;
found.into_iter().map(|t| moved(t, shift, tol)).collect()
}
fn middle(targets: &[Target2]) -> Vector2 {
let mut sum = Vector2::new(0.0, 0.0);
for target in targets {
sum += match target {
Target2::Point(p) => p.to_vector(),
Target2::Circle(c) => c.centre().to_vector(),
Target2::Line(axis) => axis.location.to_vector(),
};
}
#[allow(clippy::cast_precision_loss)]
let count = targets.len().max(1) as f64;
sum * (1.0 / count)
}
fn shifted(target: &Target2, by: Vector2, tol: Tolerances) -> OgeomResult<Target2> {
Ok(match target {
Target2::Point(p) => Target2::Point(*p + by),
Target2::Line(axis) => Target2::Line(Axis2::new(axis.location + by, axis.direction)),
Target2::Circle(c) => Target2::Circle(Circle2::new(
Frame2::new(c.centre() + by, c.frame().x()),
c.radius(),
tol,
)?),
})
}
fn moved(found: TangentCircle, by: Vector2, tol: Tolerances) -> OgeomResult<TangentCircle> {
let c = found.circle;
Ok(TangentCircle {
circle: Circle2::new(Frame2::new(c.centre() + by, c.frame().x()), c.radius(), tol)?,
placements: found.placements,
})
}
fn of_radius_near(
radius: f64,
targets: &[Target2; 2],
tol: Tolerances,
) -> OgeomResult<Vec<TangentCircle>> {
if !radius.is_finite() || radius <= tol.confusion() {
ogeom_bail!(
Construction,
"a tangent circle of radius {radius} is not a circle"
);
}
if targets_coincide(&targets[0], &targets[1], tol) {
ogeom_bail!(Construction, "the two targets coincide");
}
let mut out: Vec<TangentCircle> = Vec::new();
let radius_row: Row = ([0.0, 0.0, 1.0, 0.0], radius);
for &s0 in sides_of(&targets[0]) {
for &s1 in sides_of(&targets[1]) {
let rows = [
rows_for(&targets[0], s0),
rows_for(&targets[1], s1),
radius_row,
];
for candidate in solve_rows(&rows, tol) {
let three = [targets[0], targets[1], targets[1]];
let mut kept = out.clone();
admit(&mut kept, candidate, &three, tol);
if kept.len() > out.len() {
let solution = kept[kept.len() - 1].circle;
let placements = [
placement_of(&solution, &targets[0], tol),
placement_of(&solution, &targets[1], tol),
placement_of(&solution, &targets[1], tol),
];
out.push(TangentCircle {
circle: solution,
placements,
});
}
}
}
}
Ok(out)
}
#[must_use]
pub fn lines_tangent_to_two_circles(a: &Circle2, b: &Circle2, tol: Tolerances) -> Vec<Axis2> {
let e = b.centre() - a.centre();
let distance = e.magnitude();
if distance <= tol.confusion() {
return Vec::new();
}
let along = e / distance;
let across = Vector2::new(-along.y, along.x);
let mut out = Vec::new();
for (sa, sb) in [(1.0, 1.0), (1.0, -1.0)] {
let reach = sa * a.radius() - sb * b.radius();
let gap = reach.abs() - distance;
if gap > tol.confusion() {
continue;
}
let touching = gap.abs() <= tol.confusion();
let k = (reach / distance).clamp(-1.0, 1.0);
let across_part = if touching { 0.0 } else { (1.0 - k * k).sqrt() };
let flips: &[f64] = if touching { &[1.0] } else { &[1.0, -1.0] };
for &flip in flips {
let n = along * -k + across * (across_part * flip);
let d = n.dot(a.centre().to_vector()) - sa * a.radius();
let mid = a.centre() + e * 0.5;
let foot = mid - n * (n.dot(mid.to_vector()) - d);
if let Ok(direction) = Direction2::new(Vector2::new(n.y, -n.x), tol) {
out.push(Axis2::new(foot, direction));
}
}
}
out
}
fn solve_rows(rows: &[Row; 3], tol: Tolerances) -> Vec<(Point2, f64)> {
let uses_q = rows.iter().any(|(coeffs, _)| coeffs[3] != 0.0);
if uses_q {
solve_with_q(rows, tol)
} else {
solve_linear(rows, tol)
}
}
fn solve_linear(rows: &[Row; 3], _tol: Tolerances) -> Vec<(Point2, f64)> {
let m = nalgebra::Matrix3::new(
rows[0].0[0],
rows[0].0[1],
rows[0].0[2],
rows[1].0[0],
rows[1].0[1],
rows[1].0[2],
rows[2].0[0],
rows[2].0[1],
rows[2].0[2],
);
let b = nalgebra::Vector3::new(rows[0].1, rows[1].1, rows[2].1);
let Some(solution) = m.lu().solve(&b) else {
return Vec::new();
};
vec![(Point2::new(solution[0], solution[1]), solution[2])]
}
fn solve_with_q(rows: &[Row; 3], tol: Tolerances) -> Vec<(Point2, f64)> {
let m = [rows[0].0, rows[1].0, rows[2].0];
let b = [rows[0].1, rows[1].1, rows[2].1];
let minor = |skip: usize| -> f64 {
let cols: Vec<usize> = (0..4).filter(|c| *c != skip).collect();
nalgebra::Matrix3::new(
m[0][cols[0]],
m[0][cols[1]],
m[0][cols[2]],
m[1][cols[0]],
m[1][cols[1]],
m[1][cols[2]],
m[2][cols[0]],
m[2][cols[1]],
m[2][cols[2]],
)
.determinant()
};
let null: [f64; 4] = [minor(0), -minor(1), minor(2), -minor(3)];
let biggest = null.iter().fold(0.0_f64, |a, v| a.max(v.abs()));
if biggest <= 1e-12 {
return Vec::new();
}
let pin = (0..4)
.max_by(|a, b| {
minor(*a)
.abs()
.partial_cmp(&minor(*b).abs())
.unwrap_or(core::cmp::Ordering::Equal)
})
.unwrap_or(3);
let cols: Vec<usize> = (0..4).filter(|c| *c != pin).collect();
let square = nalgebra::Matrix3::new(
m[0][cols[0]],
m[0][cols[1]],
m[0][cols[2]],
m[1][cols[0]],
m[1][cols[1]],
m[1][cols[2]],
m[2][cols[0]],
m[2][cols[1]],
m[2][cols[2]],
);
let rhs = nalgebra::Vector3::new(b[0], b[1], b[2]);
let Some(solved) = square.lu().solve(&rhs) else {
return Vec::new();
};
let mut particular = [0.0f64; 4];
for (slot, col) in cols.iter().enumerate() {
particular[*col] = solved[slot];
}
let (px, py, pr, pq) = (particular[0], particular[1], particular[2], particular[3]);
let (nx, ny, nr, nq) = (null[0], null[1], null[2], null[3]);
let a2 = nx * nx + ny * ny - nr * nr;
let a1 = 2.0 * (px * nx + py * ny - pr * nr) - nq;
let a0 = px * px + py * py - pr * pr - pq;
let mut lambdas = Vec::new();
if a2.abs() <= 1e-14 * (a1.abs().max(a0.abs()).max(1.0)) {
if a1.abs() > 1e-14 {
lambdas.push(-a0 / a1);
}
} else {
lambdas.push(-a1 / (2.0 * a2));
let disc = a1.mul_add(a1, -4.0 * a2 * a0);
if disc > 0.0 {
let root = disc.sqrt();
lambdas.push((-a1 + root) / (2.0 * a2));
lambdas.push((-a1 - root) / (2.0 * a2));
}
}
lambdas
.into_iter()
.map(|l| (Point2::new(px + l * nx, py + l * ny), pr + l * nr))
.filter(|(_, r)| r.is_finite() && *r > tol.confusion())
.collect()
}
fn admit(
out: &mut Vec<TangentCircle>,
(centre, radius): (Point2, f64),
targets: &[Target2; 3],
tol: Tolerances,
) {
let slack = tol.confusion() * 1e3 * radius.max(1.0);
for target in targets {
let touch = match target {
Target2::Point(p) => (centre.distance(*p) - radius).abs(),
Target2::Line(axis) => (axis.distance_to(centre) - radius).abs(),
Target2::Circle(c) => {
let d = centre.distance(c.centre());
(d - (radius + c.radius()))
.abs()
.min((d - (radius - c.radius()).abs()).abs())
}
};
if touch > slack {
return;
}
}
if out.iter().any(|held| {
held.circle.centre().distance(centre) <= slack
&& (held.circle.radius() - radius).abs() <= slack
}) {
return;
}
let Ok(circle) = Circle2::new(Frame2::new(centre, Direction2::X), radius, tol) else {
return;
};
let placements = [
placement_of(&circle, &targets[0], tol),
placement_of(&circle, &targets[1], tol),
placement_of(&circle, &targets[2], tol),
];
out.push(TangentCircle { circle, placements });
}
fn placement_of(circle: &Circle2, target: &Target2, tol: Tolerances) -> Placement {
match target {
Target2::Point(_) => Placement::Through,
Target2::Line(_) => Placement::Tangent,
Target2::Circle(c) => {
let d = circle.centre().distance(c.centre());
let slack = tol.confusion() * 1e3 * circle.radius().max(1.0);
if (d - (circle.radius() + c.radius())).abs() <= slack {
Placement::Outside
} else if circle.radius() >= c.radius()
&& (d - (circle.radius() - c.radius())).abs() <= slack
{
Placement::Enclosing
} else {
Placement::Enclosed
}
}
}
}
fn targets_coincide(a: &Target2, b: &Target2, tol: Tolerances) -> bool {
match (a, b) {
(Target2::Point(p), Target2::Point(q)) => p.is_equal(*q, tol),
(Target2::Circle(c), Target2::Circle(d)) => {
c.centre().is_equal(d.centre(), tol)
&& (c.radius() - d.radius()).abs() <= tol.confusion()
}
(Target2::Line(a), Target2::Line(b)) => {
let na = normal_of(a);
let nb = normal_of(b);
na.cross(nb).abs() <= tol.angular() && a.distance_to(b.location) <= tol.confusion()
}
_ => false,
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Bisector2 {
Line(Axis2),
Pair([Axis2; 2]),
Parabola(Parabola2),
Ellipse(Ellipse2),
Hyperbola(Hyperbola2),
}
pub fn bisector(a: &Target2, b: &Target2, tol: Tolerances) -> OgeomResult<Bisector2> {
if targets_coincide(a, b, tol) {
ogeom_bail!(Construction, "coincident targets bisect everywhere");
}
match (a, b) {
(Target2::Point(p), Target2::Point(q)) => {
let mid = *p + (*q - *p) * 0.5;
let direction = Direction2::new(perp(*q - *p), tol)?;
Ok(Bisector2::Line(Axis2::new(mid, direction)))
}
(Target2::Line(l), Target2::Line(m)) => {
let nl = normal_of(l);
let nm = normal_of(m);
let dl = nl.dot(l.location.to_vector());
let dm = nm.dot(m.location.to_vector());
if nl.cross(nm).abs() <= tol.angular() {
let (nm, dm) = if nl.dot(nm) < 0.0 {
(-nm, -dm)
} else {
(nm, dm)
};
let _ = nm;
let offset = f64::midpoint(dl, dm);
let foot = Point2::new(nl.x * offset, nl.y * offset);
return Ok(Bisector2::Line(Axis2::new(foot, l.direction)));
}
let apex = intersect_lines(nl, dl, nm, dm)?;
let d1 = Direction2::new(l.direction.vector() + m.direction.vector(), tol)
.or_else(|_| Direction2::new(perp(l.direction.vector()), tol))?;
let d2 = Direction2::new(perp(d1.vector()), tol)?;
Ok(Bisector2::Pair([
Axis2::new(apex, d1),
Axis2::new(apex, d2),
]))
}
(Target2::Point(p), Target2::Line(l)) | (Target2::Line(l), Target2::Point(p)) => {
let n = normal_of(l);
let signed = n.dot(*p - l.location);
if signed.abs() <= tol.confusion() {
ogeom_bail!(
Construction,
"the point lies on the line; the locus degenerates"
);
}
let foot = *p - n * signed;
let apex = foot + (*p - foot) * 0.5;
let x = Direction2::new(*p - foot, tol)?;
let frame = Frame2::new(apex, x);
Ok(Bisector2::Parabola(Parabola2::new(
frame,
signed.abs() / 2.0,
tol,
)?))
}
(Target2::Point(p), Target2::Circle(c)) | (Target2::Circle(c), Target2::Point(p)) => {
let spread = p.distance(c.centre());
let r = c.radius();
if (spread - r).abs() <= tol.confusion() {
ogeom_bail!(
Construction,
"the point lies on the circle; the locus degenerates"
);
}
foci_conic(c.centre(), *p, r, spread, tol)
}
(Target2::Line(l), Target2::Circle(c)) | (Target2::Circle(c), Target2::Line(l)) => {
let n = normal_of(l);
let signed = n.dot(c.centre() - l.location);
if signed.abs() <= c.radius() + tol.confusion() {
ogeom_bail!(
Construction,
"the line meets the circle; the equidistant locus is not one conic"
);
}
let toward = if signed > 0.0 { n } else { -n };
let directrix_foot = l.location + perp_foot_shift(l, c.centre()) - toward * c.radius();
let focus = c.centre();
let foot_to_focus = focus - directrix_foot;
let apex = directrix_foot + foot_to_focus * 0.5;
let x = Direction2::new(foot_to_focus, tol)?;
Ok(Bisector2::Parabola(Parabola2::new(
Frame2::new(apex, x),
foot_to_focus.magnitude() / 2.0,
tol,
)?))
}
(Target2::Circle(c1), Target2::Circle(c2)) => {
let spread = c1.centre().distance(c2.centre());
if spread <= tol.confusion() {
let radius = f64::midpoint(c1.radius(), c2.radius());
let circle = Circle2::new(Frame2::new(c1.centre(), Direction2::X), radius, tol)?;
let _ = circle;
ogeom_bail!(
Construction,
"concentric circles bisect on a circle; ask for it as one"
);
}
if (c1.radius() - c2.radius()).abs() <= tol.confusion() {
let mid = c1.centre() + (c2.centre() - c1.centre()) * 0.5;
let direction = Direction2::new(perp(c2.centre() - c1.centre()), tol)?;
return Ok(Bisector2::Line(Axis2::new(mid, direction)));
}
let difference = (c1.radius() - c2.radius()).abs();
if difference >= spread - tol.confusion() {
ogeom_bail!(
Construction,
"one circle encloses the other too deeply; the locus degenerates"
);
}
let centre = c1.centre() + (c2.centre() - c1.centre()) * 0.5;
let (larger, smaller) = if c1.radius() > c2.radius() {
(c1, c2)
} else {
(c2, c1)
};
let x = Direction2::new(smaller.centre() - larger.centre(), tol)?;
let a_half = difference / 2.0;
let c_half = spread / 2.0;
let b_half = (c_half * c_half - a_half * a_half).sqrt();
Ok(Bisector2::Hyperbola(Hyperbola2::new(
Frame2::new(centre, x),
a_half,
b_half,
tol,
)?))
}
}
}
fn foci_conic(
circle_centre: Point2,
point: Point2,
r: f64,
spread: f64,
tol: Tolerances,
) -> OgeomResult<Bisector2> {
let centre = circle_centre + (point - circle_centre) * 0.5;
let x = Direction2::new(point - circle_centre, tol)?;
let a_half = r / 2.0;
let c_half = spread / 2.0;
if spread < r {
let b_half = (a_half * a_half - c_half * c_half).sqrt();
Ok(Bisector2::Ellipse(Ellipse2::new(
Frame2::new(centre, x),
a_half,
b_half,
tol,
)?))
} else {
let b_half = (c_half * c_half - a_half * a_half).sqrt();
Ok(Bisector2::Hyperbola(Hyperbola2::new(
Frame2::new(centre, x),
a_half,
b_half,
tol,
)?))
}
}
fn perp(v: Vector2) -> Vector2 {
Vector2::new(-v.y, v.x)
}
fn perp_foot_shift(axis: &Axis2, to: Point2) -> Vector2 {
let along = axis.direction.vector();
along * along.dot(to - axis.location)
}
fn intersect_lines(n1: Vector2, d1: f64, n2: Vector2, d2: f64) -> OgeomResult<Point2> {
let det = n1.x * n2.y - n1.y * n2.x;
if det.abs() <= f64::MIN_POSITIVE {
ogeom_bail!(Construction, "parallel lines do not meet");
}
Ok(Point2::new(
(d1 * n2.y - d2 * n1.y) / det,
(n1.x * d2 - n2.x * d1) / det,
))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
const T: Tolerances = Tolerances::millimetres();
fn circle(x: f64, y: f64, r: f64) -> Circle2 {
Circle2::new(Frame2::new(Point2::new(x, y), Direction2::X), r, T).unwrap()
}
fn assert_tangent(solutions: &[TangentCircle], targets: &[Target2; 3]) {
assert!(!solutions.is_empty(), "the construction found nothing");
for s in solutions {
for target in targets {
let gap = match target {
Target2::Point(p) => (s.circle.centre().distance(*p) - s.circle.radius()).abs(),
Target2::Line(l) => {
(l.distance_to(s.circle.centre()) - s.circle.radius()).abs()
}
Target2::Circle(c) => {
let d = s.circle.centre().distance(c.centre());
(d - (s.circle.radius() + c.radius()))
.abs()
.min((d - (s.circle.radius() - c.radius()).abs()).abs())
}
};
assert!(gap < 1e-9, "tangency gap {gap} on {target:?} for {s:?}");
}
}
}
#[test]
fn three_points_give_the_circumcircle() {
let targets = [
Target2::Point(Point2::new(0.0, 0.0)),
Target2::Point(Point2::new(4.0, 0.0)),
Target2::Point(Point2::new(0.0, 3.0)),
];
let found = circles_tangent_to_three(&targets, T).unwrap();
assert_eq!(found.len(), 1);
assert!((found[0].circle.radius() - 2.5).abs() < 1e-9);
assert_tangent(&found, &targets);
}
#[test]
fn three_lines_give_the_incircle_and_excircles() {
let targets = [
Target2::Line(Axis2::new(Point2::new(0.0, 0.0), Direction2::X)),
Target2::Line(Axis2::new(Point2::new(0.0, 0.0), Direction2::Y)),
Target2::Line(Axis2::new(
Point2::new(4.0, 0.0),
Direction2::new(Vector2::new(-4.0, 3.0), T).unwrap(),
)),
];
let found = circles_tangent_to_three(&targets, T).unwrap();
assert_eq!(found.len(), 4, "incircle and three excircles: {found:?}");
assert!(
found.iter().any(|s| (s.circle.radius() - 1.0).abs() < 1e-9),
"the incircle of 3-4-5 has radius 1"
);
assert_tangent(&found, &targets);
}
#[test]
fn apollonius_three_circles_yields_eight() {
let targets = [
Target2::Circle(circle(0.0, 0.0, 1.0)),
Target2::Circle(circle(6.0, 0.0, 1.5)),
Target2::Circle(circle(2.5, 5.0, 2.0)),
];
let found = circles_tangent_to_three(&targets, T).unwrap();
assert_eq!(found.len(), 8, "Apollonius promises eight: {}", found.len());
assert_tangent(&found, &targets);
assert!(
found
.iter()
.any(|s| s.placements == [Placement::Outside; 3])
);
assert!(
found
.iter()
.any(|s| s.placements == [Placement::Enclosing; 3])
);
}
#[test]
fn mixed_targets_and_fixed_radius_answer() {
let targets = [
Target2::Point(Point2::new(1.0, 2.0)),
Target2::Line(Axis2::new(Point2::new(0.0, -1.0), Direction2::X)),
Target2::Circle(circle(5.0, 3.0, 1.0)),
];
let found = circles_tangent_to_three(&targets, T).unwrap();
assert_tangent(&found, &targets);
let two = [
Target2::Line(Axis2::new(Point2::new(0.0, 0.0), Direction2::X)),
Target2::Circle(circle(0.0, 5.0, 1.0)),
];
let sized = circles_of_radius_tangent_to_two(2.0, &two, T).unwrap();
assert!(!sized.is_empty());
for s in &sized {
assert!((s.circle.radius() - 2.0).abs() < 1e-9);
let d0 = Target2::distance_to(&two[0], s.circle.centre());
let d1 = Target2::distance_to(&two[1], s.circle.centre());
assert!((d0 - 2.0).abs() < 1e-9 && (d1 - 2.0).abs() < 1e-9, "{s:?}");
}
}
#[test]
fn bitangent_lines_touch_both_circles() {
let a = circle(0.0, 0.0, 2.0);
let b = circle(8.0, 0.0, 1.0);
let lines = lines_tangent_to_two_circles(&a, &b, T);
assert_eq!(lines.len(), 4, "external pair and internal pair");
for line in &lines {
assert!((line.distance_to(a.centre()) - 2.0).abs() < 1e-9);
assert!((line.distance_to(b.centre()) - 1.0).abs() < 1e-9);
}
}
#[test]
fn touching_circles_have_three_tangent_lines() {
for (b, expected, touch) in [
(circle(3.0, 0.0, 2.0), 3, Point2::new(1.0, 0.0)),
(circle(1.0, 0.0, 2.0), 1, Point2::new(-1.0, 0.0)),
] {
let a = circle(0.0, 0.0, 1.0);
let lines = lines_tangent_to_two_circles(&a, &b, T);
assert_eq!(lines.len(), expected, "{b:?}");
for line in &lines {
assert!((line.distance_to(a.centre()) - a.radius()).abs() < 1e-9);
assert!((line.distance_to(b.centre()) - b.radius()).abs() < 1e-9);
}
assert!(lines.iter().any(|line| line.distance_to(touch) < 1e-9));
}
}
fn assert_equidistant(bisector: &Bisector2, a: &Target2, b: &Target2) {
let probes: Vec<Point2> = match bisector {
Bisector2::Line(axis) => (-5..=5)
.map(|i| axis.location + axis.direction.vector() * f64::from(i))
.collect(),
Bisector2::Pair(axes) => axes
.iter()
.flat_map(|axis| {
(-3..=3).map(move |i| axis.location + axis.direction.vector() * f64::from(i))
})
.collect(),
Bisector2::Parabola(p) => (-5..=5)
.map(|i| {
let t = f64::from(i);
let frame = p.frame();
frame.origin()
+ frame.x().vector() * (t * t / (4.0 * p.focal()))
+ frame.y().vector() * t
})
.collect(),
Bisector2::Ellipse(e) => (0..12)
.map(|i| {
let t = core::f64::consts::TAU * f64::from(i) / 12.0;
let frame = e.frame();
frame.origin()
+ frame.x().vector() * (e.major_radius() * t.cos())
+ frame.y().vector() * (e.minor_radius() * t.sin())
})
.collect(),
Bisector2::Hyperbola(h) => (-3..=3)
.map(|i| {
let t = 0.6 * f64::from(i);
let frame = h.frame();
frame.origin()
+ frame.x().vector() * (h.major_radius() * t.cosh())
+ frame.y().vector() * (h.minor_radius() * t.sinh())
})
.collect(),
};
for p in probes {
let (da, db) = (a.distance_to(p), b.distance_to(p));
assert!(
(da - db).abs() < 1e-9,
"not equidistant at {p:?}: {da} vs {db} for {bisector:?}"
);
}
}
#[test]
fn bisectors_are_equidistant_loci() {
let point = Target2::Point(Point2::new(1.0, 1.0));
let other = Target2::Point(Point2::new(-1.0, 2.0));
let line = Target2::Line(Axis2::new(Point2::new(0.0, -2.0), Direction2::X));
let small = Target2::Circle(circle(0.0, 0.0, 5.0));
let far = Target2::Circle(circle(12.0, 0.0, 2.0));
assert_equidistant(&bisector(&point, &other, T).unwrap(), &point, &other);
assert_equidistant(&bisector(&point, &line, T).unwrap(), &point, &line);
let inside = bisector(&point, &small, T).unwrap();
assert!(matches!(inside, Bisector2::Ellipse(_)), "{inside:?}");
assert_equidistant(&inside, &point, &small);
let between = bisector(&small, &far, T).unwrap();
assert!(matches!(between, Bisector2::Hyperbola(_)), "{between:?}");
assert_equidistant(&between, &small, &far);
let between = bisector(&far, &small, T).unwrap();
assert_equidistant(&between, &far, &small);
let slanted = Target2::Line(Axis2::new(
Point2::new(0.0, -2.0),
Direction2::new(Vector2::new(1.0, 1.0), T).unwrap(),
));
let pair = bisector(&line, &slanted, T).unwrap();
assert!(matches!(pair, Bisector2::Pair(_)), "{pair:?}");
assert_equidistant(&pair, &line, &slanted);
}
#[test]
fn tangent_circles_far_from_the_origin_keep_their_digits() {
for offset in [0.0, 1_000.0, 10_000.0] {
let radius: f64 = 1e-3;
let through = [0.3_f64, 2.0, 4.1].map(|a| {
Target2::Point(Point2::new(
radius.mul_add(a.cos(), offset),
radius.mul_add(a.sin(), offset),
))
});
let found = circles_tangent_to_three(&through, T).unwrap();
assert_eq!(found.len(), 1, "{offset}");
let circle = found[0].circle;
assert!((circle.radius() - radius).abs() < 1e-12, "{offset}");
assert!(circle.centre().distance(Point2::new(offset, offset)) < 1e-12);
}
}
}