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 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
159pub 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 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#[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 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 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
246fn 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
257fn 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
277fn 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 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 return Vec::new();
308 }
309
310 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 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 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
374fn 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#[derive(Debug, Clone, Copy, PartialEq)]
455pub enum Bisector2 {
456 Line(Axis2),
458 Pair([Axis2; 2]),
460 Parabola(Parabola2),
462 Ellipse(Ellipse2),
464 Hyperbola(Hyperbola2),
469}
470
471pub 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 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 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 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 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 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 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 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 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
622fn 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 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 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
661fn 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 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 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 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 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 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 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 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 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 let inside = bisector(&point, &small, T).unwrap();
871 assert!(matches!(inside, Bisector2::Ellipse(_)), "{inside:?}");
872 assert_equidistant(&inside, &point, &small);
873 let between = bisector(&small, &far, T).unwrap();
875 assert!(matches!(between, Bisector2::Hyperbola(_)), "{between:?}");
876 assert_equidistant(&between, &small, &far);
877 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}