1use 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#[derive(Debug, Clone, Copy, PartialEq)]
32pub enum Target2 {
33 Point(Point2),
35 Line(Axis2),
37 Circle(Circle2),
39}
40
41impl Target2 {
42 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Placement {
57 Through,
59 Tangent,
61 Outside,
64 Enclosing,
66 Enclosed,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct TangentCircle {
73 pub circle: Circle2,
75 pub placements: [Placement; 3],
77}
78
79type Row = ([f64; 4], f64);
82
83fn rows_for(target: &Target2, side: f64) -> Row {
84 match target {
85 Target2::Point(p) => {
86 ([-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
105fn normal_of(axis: &Axis2) -> Vector2 {
107 let d = axis.direction.vector();
108 Vector2::new(-d.y, d.x)
109}
110
111fn sides_of(target: &Target2) -> &'static [f64] {
113 match target {
114 Target2::Point(_) => &[1.0],
115 _ => &[1.0, -1.0],
116 }
117}
118
119pub fn circles_tangent_to_three(
128 targets: &[Target2; 3],
129 tol: Tolerances,
130) -> OgeomResult<Vec<TangentCircle>> {
131 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
176pub fn circles_of_radius_tangent_to_two(
184 radius: f64,
185 targets: &[Target2; 2],
186 tol: Tolerances,
187) -> OgeomResult<Vec<TangentCircle>> {
188 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
198fn 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
214fn 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
227fn 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 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#[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 for (sa, sb) in [(1.0, 1.0), (1.0, -1.0)] {
297 let reach = sa * a.radius() - sb * b.radius();
298 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 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
323fn 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
334fn 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
354fn 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 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 return Vec::new();
385 }
386
387 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 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 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
451fn 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#[derive(Debug, Clone, Copy, PartialEq)]
532pub enum Bisector2 {
533 Line(Axis2),
535 Pair([Axis2; 2]),
537 Parabola(Parabola2),
539 Ellipse(Ellipse2),
541 Hyperbola(Hyperbola2),
546}
547
548pub 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 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 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 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 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 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 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 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 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 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
706fn 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 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 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
745fn 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 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 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 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 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 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 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 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 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 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 let inside = bisector(&point, &small, T).unwrap();
974 assert!(matches!(inside, Bisector2::Ellipse(_)), "{inside:?}");
975 assert_equidistant(&inside, &point, &small);
976 let between = bisector(&small, &far, T).unwrap();
978 assert!(matches!(between, Bisector2::Hyperbola(_)), "{between:?}");
979 assert_equidistant(&between, &small, &far);
980 let between = bisector(&far, &small, T).unwrap();
983 assert_equidistant(&between, &far, &small);
984 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 #[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}