Két kör metszéspontjai analitikusan, képlettel kiszámíthatók. De most nem így oldjuk meg a feladatot, hanem a két kör adott számú pontjának felvételével, és ezek közül a legközelebbi pontok meghatározásával, majd e térrészben egyre több újabb pont generálásával és újabb közelségvizsgálattal. Ezt ismételjük addig, amíg a körök két vizsgált pontjának távolsága egy előre meghatározott hibahatáron belül nem lesz. Ekkor a két pontot a közös metszéspontnak tekintjük.
Miért választunk a képlettel történő számítás helyett egy numerikus, iteratív megoldást? Azért, mert most valójában nem a konkrét feladat eredménye az érdekes, hanem az odavezető út és az alkalmazott Python nyelvi elemek és szerkezetek gyakorlása. Ennek során látni fogjuk, hogy hogyan lehet egy problémát értékkeresési feladattá alakítani, hogyan lehet a keresési tartományt szűkíteni ahhoz, hogy a számításigényt minimalizáljuk, és hogyan működik az iteratív, fokozatosan közelítő eljárás.
Ráadásul a bemutatott megközelítés nem kizárólag körökre alkalmazható. Az alakzatok egy előre meghatározott elv szerinti relatív pozícióba hozása, majd egy szűkített paramétertartományban végzett numerikus egyezéskeresés némi módosítással szabályos konvex sokszögekre is adaptálható. Ez különösen nagyobb élszám esetén merülhet fel, mert a képleteken alapuló analitikus megoldás ilyenkor nehézkessé válik.
Mindemellett persze a konkrét feladathoz a geometriai ismereteinkre is szükség lesz, mert alkalmazni fogunk polárkoordinátákat, eltolást, forgatást és ezek inverz transzformációit, pontok távolságának számítását és körök pontjainak meghatározását.
Első lépésként a két kört egy rögzített, standard relatív helyzetbe transzformáljuk: a nagyobb kör középpontját az origóba toljuk, majd a kisebb kör középpontját a pozitív x tengelyre forgatjuk. Ezt szemlélteti a következő ábra, ahol ennek az elrendezésnek az előnyeit is felsoroltuk. E jellemzők biztosítják, hogy a számítások egyszerűbbek legyenek és kevesebb iterációt igényeljenek, hiszen egyre szűkülő szögtartományokban kell legközelebb eső pontok keresését végezni.

E geometriai transzformációkat, valamint a legközlebbi pontok keresését kell lekódolni. A pontok és a körök előállításához egy Point és egy Circle osztályt definiálunk az alább látható módon.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 |
# Python 3.12+ from dataclasses import dataclass from math import radians, degrees, cos, sin, asin, pi, dist, isclose import cmath from itertools import product from typing import Generator, Self type RealNum = int | float @dataclass(frozen=True, slots=True) class Point: x: RealNum y: RealNum def __str__(self): return f"{type(self).__name__}(x={self.x:+.2f}, y={self.y:+.2f})" def __iter__(self): return iter((self.x, self.y)) def to_polar(self, in_degrees: bool = False, ndigits: int = 16) -> tuple[RealNum, RealNum]: """A pont mint az origóból induló helyvektor polárkoordinátáit adja vissza, vagyis annak hosszával és a pozitív x tengellyel bezárt szögével tér vissza. Ha az 'in_degrees' True, akkor a szög fokban értendő, ellenkező esetben radiánban. Az 'ndigits' a visszaadott értékek kerekítésének pontossága (tizedesjegyek száma). Az origó (0, 0) pont esetén a metódus (0, 0) értéket ad vissza, vagyis a szög értéke 0-nak van definiálva. """ r, fi = cmath.polar(complex(self.x, self.y)) if in_degrees: fi = degrees(fi) return round(r, ndigits), round(fi, ndigits) def distance_from(self, point: Self) -> float: """A 'self' és a megadott pont távolságát adja vissza.""" return dist(self, point) def shift(self, dx: RealNum, dy: RealNum) -> Self: """Olyan új példánnyal tér vissza, amelynek koordinátája a 'self' koordinátáinak és az argumentumban megadott x és y irányú eltolás mértékének az összege. """ return type(self)(self.x + dx, self.y + dy) def rotate(self, point_of_rotation: Self, angle: RealNum, in_degrees: bool = False) -> Self: """Olyan új példánnyal tér vissza, amely a 'self'-hez képest a 'point_of_rotation' paraméternek megadott forgáspont körül 'angle' szöggel van elforgatva. A pozitív szögérték az óramutató járásával ellentétes forgatást jelent. Ha a szög fokokban értendő, akkor az 'in_degrees' True kell, hogy legyen. """ if in_degrees: angle = radians(angle) # A forgásponttal eltoljuk a pontot. p_shifted_x = self.x - point_of_rotation.x p_shifted_y = self.y - point_of_rotation.y # Forgatás (pozitív szög ellentétes óramutató járással) x_rot = p_shifted_x * cos(angle) - p_shifted_y * sin(angle) y_rot = p_shifted_x * sin(angle) + p_shifted_y * cos(angle) return type(self)(x_rot + point_of_rotation.x, y_rot + point_of_rotation.y) class Circle: def __init__(self, r: RealNum, center_point: Point = Point(0, 0)): self.r = r self.center_point = center_point def __repr__(self): return '{}({}, {})'.format(type(self).__name__, self.r, self.center_point) def centers_distance(self, circle: Self) -> float: """A 'self' és a 'circle' példány középpontjainak távolsága.""" return self.center_point.distance_from(circle.center_point) def translate(self, dx: RealNum, dy: RealNum) -> Self: """Olyan új Cirlce példánnyal tér vissza, amely középpontjának koordinátái a 'self' középpontjához képest (dx, dy) értékkel el van tolva. Ha például a 'self' középpontja (1,1) és az eltolás mértéke (1,-2), akkor az új kör középpontja (2, -1) lesz. """ return type(self)(self.r, self.center_point.shift(dx, dy)) def rotate(self, point_of_rotation: Point, angle: RealNum, in_degree: bool = False) -> Self: """Olyan új példánnyal tér vissza, amelynek a középpontja a 'self' középpontjához képest a megadott forgáspont körül a megadott szöggel van elforgatva. A pozitív szögérték az óramutató járásával ellentétes forgatást jelent. Ha az 'in_degree' paraméter True, akkor a szögek fokokban értendők, ellenkező esetben radiánban. """ return type(self)(self.r, self.center_point.rotate(point_of_rotation, angle, in_degree)) def points_generator(self, from_angle: RealNum, to_angle: RealNum, num_points: int, in_degrees: bool = False) -> Generator[Point, None, None]: """ Egy generátort ad vissza, amely a kör 'num_points' darab pontját szolgáltatja. A pontok a kör középpontjából húzott sugár és az x tengely által bezárt szög alapján számolodónak a 'from_angle' és 'to_angle' értékekkel meghatározott szögtartományon belül. Ha az 'in_degrees' paraméter True, akkor a szögek fokokban értendők, ellenkező esetben radiánban. """ if in_degrees: from_angle, to_angle = radians(from_angle), radians(to_angle) # A megadott szögtartományon belül a megadott pontok száma alapján a szögnövekmény. delta_angle = (to_angle - from_angle) / num_points # A szögtartományon belüli szögértékek előállítása. angles = (from_angle + i * delta_angle for i in range(num_points)) return (Point(self.r * cos(angle), self.r * sin(angle)).shift(*self.center_point) for angle in angles) def _canonicalize_circle_pair(self, circle: Self) -> tuple[Self, Self, Point, float]: """A 'self' és 'circle' körök olyan új transzformált változatát állítjuk elő, hogy közülük a nagyobb sugarú középpontját az origóba toljuk és ugyanennyivel eltoljuk a kisebbet. Utána a kisebb kört elforgatjuk úgy, hogy középpontja a pozitív x tengelyre kerüljön. A metódus visszaadja az így transzformált nagyobb és kisebb kört, valamint az alkalmazott eltolást és forgatási szöget. """ circle_bigger, circle_smaller = (self, circle) if self.r > circle.r else (circle, self) shift = circle_bigger.center_point # A nagyobb kör eltolási helyvektora. circle_bigger_canon = circle_bigger.translate(-shift.x, -shift.y) # A nagyobb kör az eltolás után (origó középponttal) circle_smaller_translated = circle_smaller.translate(-shift.x, -shift.y) center_point_smaller = circle_smaller_translated.center_point # A kisebb kör középpontja az eltolás után. _, fi_cp_smaller = center_point_smaller.to_polar() rotation_angle = fi_cp_smaller # A kisebb kör az eltolás és forgatás után (középpontja az x tengelyen az origótól d távolságra). circle_smaller_canon = circle_smaller_translated.rotate(Point(0, 0), -rotation_angle) return circle_bigger_canon, circle_smaller_canon, shift, rotation_angle def intersection_points(self, circle: Self) -> set[Point]: """Visszaadja a 'self' és a megadott 'circle' kör metszéspontjait egy halmazban, amelynek elemszáma 0, 1 vagy 2, mert két kör legfeljebb két pontban metszi egymást. Ha a két kör azonos, akkor üres halmazt ad vissza. """ # Kizárjuk azokat az eseteket, amikor nem lehet metszéspont vagy végtelen sok a metszéspont (azonos a két kör). d = self.centers_distance(circle) if (d > self.r + circle.r) or (d < abs(self.r - circle.r)) or isclose(d, 0): return set() # Legalább egy metszéspont esetén a számítási lépések: # 1. Körök transzformálása standard relatív pozícióba a könnyebb számítás érdekében. _circle_big, _circle_small, shift, fi = self._canonicalize_circle_pair(circle) # 2. Inicializáljuk a szögtartományokat és egyéb paramétereket from_angle1, to_angle1 = 0, asin(_circle_small.r / _circle_big.r) # A nagyobb kör maximális kezdeti szögtartománya. from_angle2, to_angle2 = 0, pi # A kisebb kör maximális kezdeti szögtartománya. n = 100 # A generált és vizsgált pontok száma. distance_tolerance = 1e-13 # Hibahatár a két pont távolságára, amelyen belül azonosnak tekintjük a két pontot. iteration_count = 0 # Az iterációk számlálója a közelítéskor. max_iterations = 1000 # Az iterációk megengedett maximális száma. # 3. Iteratív numerikus közelítés while iteration_count <= max_iterations: # 3a. A két kör pontjai közül megkeressük a legközelebbi pontpárt az aktuális szögtartományban. p1, p2, min_distance = self._find_closest_points(_circle_big, _circle_small, from_angle1, to_angle1, from_angle2, to_angle2, n) # 3b. Ha a pontok elég közel vannak, előállítjuk és visszaadjuk az eredeti két kör valós metszéspontjait. if min_distance < distance_tolerance: return self._construct_intersection_points(p1, shift, fi) # 3c. Mivel a pontok nem voltak elég közel egymáshoz, azért szűkítjük a szögtartományt a következő iterációhoz. from_angle1, to_angle1, from_angle2, to_angle2 = self._narrow_angle_ranges(from_angle1, to_angle1, from_angle2, to_angle2, p1, p2, _circle_small) iteration_count += 1 # 4. Ha a megadott toleranciaérték olyan kicsi, hogy a float számok véges ábrázolási tartománya miatt a pontok közötti # távolság az iterációk számának növelésével már nem szűkíthető, akkor kivételt dobunk. raise ValueError('Túl szigorú a pontok távolságára vonatkozó toleranciaérték a float számábrázoláshoz') # ======== SEGÉDMETÓDOK ======== def _find_closest_points(self, big_circle: Self, small_circle: Self, from_angle_big: RealNum, to_angle_big: RealNum, from_angle_small: RealNum, to_angle_small: RealNum, n: int) -> tuple[Point, Point, RealNum]: """A nagyobb és kisebb kör megadott szögtartományaiban előállított pontjai közül megkeresi a legközelebbi pontpárt, és visszaadja a két pontot, valamint a köztük lévő távolságot. """ points_distance = ((p_big, p_small, p_big.distance_from(p_small)) for p_big, p_small in product(big_circle.points_generator(from_angle_big, to_angle_big, n), small_circle.points_generator(from_angle_small, to_angle_small, n) ) ) return min(points_distance, key=lambda triplet: triplet[2]) def _construct_intersection_points(self, closest_point: Point, shift: Point, fi: RealNum) -> set[Point]: """ A megadott 'closest_point' pontból előállítja a valós metszéspontokat inverz transzformáció alkalmazásával, amihez a megadott 'shift' eltolási helyvektort és 'fi' forgatási szöget használja. Ha a 'closest_point' y koordinátája 0, egyetlen metszéspont van. Egyébként két metszéspontot állít elő tükrözéssel. """ intersection_point1 = closest_point.rotate(Point(0, 0), fi).shift(*shift) # Ha y≈0, akkor a körök egyetlen pontban érintkeznek, azaz egyetlen metszéspont van. if isclose(closest_point.y, 0, abs_tol=1e-10): return {intersection_point1} # Két metszéspont esetén a második pont tükrözéssel áll elő. intersection_point2 = Point(closest_point.x, -closest_point.y).rotate(Point(0, 0), fi).shift(*shift) return {intersection_point1, intersection_point2} def _narrow_angle_ranges(self, from_angle_big: RealNum, to_angle_big: RealNum, from_angle_small: RealNum, to_angle_small: RealNum, point_on_big_circle: Point, point_on_small_circle: Point, small_circle: Self) -> tuple[RealNum, RealNum, RealNum, RealNum]: """Szűkíti a keresési szögtartományokat a következő iterációhoz. Az új szögtartomány meghatározása a pontok polárkoordinátái alapján felezéses eljárással történik. """ # A nagyobb kör pontjának polárszöge (origóhoz képest). _, angle_big = point_on_big_circle.to_polar() # A kisebb kör pontjának polárszöge a saját középpontjához képest _, angle_small = point_on_small_circle.shift(-small_circle.center_point.x, -small_circle.center_point.y).to_polar() # A nagyobb kör szögtartományának felezése. angle_mid_big = (from_angle_big + to_angle_big) / 2 if angle_big >= angle_mid_big: from_angle_big = angle_mid_big else: to_angle_big = angle_mid_big # A kisebb kör szögtartományának felezése. angle_mid_small = (from_angle_small + to_angle_small) / 2 if angle_small >= angle_mid_small: from_angle_small = angle_mid_small else: to_angle_small = angle_mid_small return from_angle_big, to_angle_big, from_angle_small, to_angle_small |
A Point osztályt adatosztállyal valósítottuk meg úgy, hogy a dataclass dekorátor frozen és slots paramétereit True értékre állítjuk. Az előbbivel biztosítjuk, hogy a Point példányok változtathatatlanok legyenek, az utóbbival pedig azt, hogy a példányok kevesebb memóriát igényeljenek. A memóriahasználatra olyankor különösen érdemes figyelni, amikor sok példány létrehozása várható. A geometriai pontok pedig általában ilyenek. A Point metódusai a pont eltolását, forgatását, valamint polárkoordinátáinak és egy másik ponttól való távolságának számítását valósítják meg.
A Circle osztály szintén rendelkezik metódusokkal, amelyek lehetővé teszik a kör eltolását és forgatását, valamint két kör középpontja távolságának számítását. Ezen felül képes arra, hogy egy megadható szögtartományban a kör adott számú pontját előállítsa azonos polárszög növekménnyel. A feladatunk szempontjából legfontosabb metódus az, amelyik a metszéspontokat határozza meg. Ezért ennek működését röviden összefoglaljuk.
Az intersection_points() metódus először ellenőrzi, hogy a körök egyáltalán metszik-e egymást. Ha a két kör egymástól távoli, vagy egyik a másikban teljesen benne van, akkor nincs metszéspont. Ilyenkor üres halmazzal tér vissza a metódus. Azonos sugarú és középpontú körök esetén sincs véges számú metszéspont. Ezért ebben az esetben is üres halmaz lesz a visszatérési érték.
Ha az előbbiek nem állnak fenn, akkor kell, hogy legyen egy vagy két metszéspont. Ekkor a következő lépések történnek:
1. A köröket a rögzített, standard relatív helyzetbe transzformálja a _canonicalize_circle_pair() metódussal.
2. Előállítja a körök pontjait a szögtartományokban.
3. Megkeresi a két kör pontjai közül a legközelebb eső kettőt.
4. Ha a pontok távolsága megfelelően kicsi, azokat metszéspontoknak tekinti és egy halmazban adja vissza.
5. Ha a pontok nem elég közeliek, a keresési tartományt szűkíti és az eljárást megismétli a 2. lépéstől.
Ha kell, hogy legyen metszéspont, de a megadott toleranciaérték olyan kicsi, hogy a float számok véges ábrázolási tartománya miatt a pontok közötti távolság az iterációk számának növelésével már nem szűkíthető, akkor ez végtelen ciklust eredményezhet. Ezért bekorlátozzuk az iterációk számát. Ha ennél több ismétlés esetén sem tudott a metódus metszéspontot visszaadni, akkor kivételt dobunk. Ilyenkor a toleranciahatárt kell tágítani addig, amíg ki nem kerül a végtelen ciklusból.
Megjegyezzük, hogy bár a pontpárok összevetésének futási ideje elvben O(n²) időkomplexitású, a rögzített pontszám (n=100) és az iterációnként szűkülő keresési tartomány miatt a tényleges futási idő kellően kicsi és jól kontrollálható. Ez rávilágít arra, hogy a keresési tartomány megfelelő korlátozásával egy elvben nagy időkomplexitású algoritmus is hatékonyan alkalmazható.
Mivel körök metszéspontjai vizuálisan jól megállapíthatók, ezért a teszteléshez egy egyszerű GUI alkalmazást készítettünk, amely megjeleníti a köröket, megjelöli a metszéspontokat és azok koordinátáit szövegesen kiírja. Ennek kódja a következő:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
import tkinter as tk class Axes: def __init__(self, canvas: tk.Canvas, center_point: Point, length_x_left, length_x_right, length_y_upper, length_y_lower): self.center_point = center_point # A vízszintes x tengely. canvas.create_line(center_point.x - length_x_left, center_point.y, center_point.x + length_x_right, center_point.y, arrow='last', arrowshape=(10, 12, 5)) # A függőleges y tengely. canvas.create_line(center_point.x, center_point.y - length_y_upper, center_point.x, center_point.y + length_y_lower, arrow='first', arrowshape=(10, 12, 5)) # A tengelyek végéhez közel az x, illetve az y feliratot jelenítjük meg. canvas.create_text(center_point.x + length_x_right + 8, center_point.y + 8, text='x', font=('Arial', 14)) canvas.create_text(center_point.x + 8, center_point.y - length_y_upper - 8, text='y', font=('Arial', 14)) class TestApp(tk.Tk): def __init__(self, canvas_width=1200, canvas_height=900): super().__init__() self.title('Két kör metszéspontja') self.canvas = tk.Canvas(self, width=canvas_width, height=canvas_height) length_limit_factor = 0.90 self.axes = Axes(self.canvas, Point(canvas_width / 2, canvas_height / 2), canvas_width / 2 * length_limit_factor, canvas_width / 2 * length_limit_factor, canvas_height / 2 * length_limit_factor, canvas_height / 2 * length_limit_factor) self.canvas.pack() self.intersection_points_var = tk.StringVar(self, value='') lbl = tk.Label(self, textvariable=self.intersection_points_var, font=('Consolas', 14, 'bold'), bg='light yellow', justify=tk.LEFT) lbl.pack(fill=tk.BOTH) def draw_circles(self, r1, center_point1: Point, r2, center_point2: Point): self.circle1, self.circle2 = Circle(r1, center_point1), Circle(r2, center_point2) for r, center_point, color in ((r1, center_point1, 'red'), (r2, center_point2, 'blue')): center_point_to_draw = Point(center_point.x + self.axes.center_point.x, -center_point.y + self.axes.center_point.y) self.canvas.create_oval(center_point_to_draw.x - r, center_point_to_draw.y - r, center_point_to_draw.x + r, center_point_to_draw.y + r, outline=color, width=3, tags=('circle1',)) dx, dy = 3, 3 self.canvas.create_oval(center_point_to_draw.x - dx, center_point_to_draw.y - dy, center_point_to_draw.x + dx, center_point_to_draw.y + dy, fill='green') cp = self.canvas.create_text(center_point_to_draw.x, center_point_to_draw.y, text=str(tuple(center_point)), anchor='n', font=('Consolas', 10)) self.canvas.move(cp, 0, 5) def draw_intersection_points(self): try: points: set[Point] = self.circle1.intersection_points(self.circle2) except ValueError as ve: self.intersection_points_var.set(str(ve)) return result = ['Metszéspont(ok):'] if points: dx, dy = 4, 4 for p in points: result.append(str(p)) p_to_draw = Point(p.x + self.axes.center_point.x, -p.y + self.axes.center_point.y) self.canvas.create_oval(p_to_draw.x - dx, p_to_draw.y - dy, p_to_draw.x + dx, p_to_draw.y + dy, fill='black') else: result.append('nincs') self.intersection_points_var.set(' '.join(result)) def run(self): self.mainloop() test_app = TestApp(800, 600) test_app.draw_circles(200, Point(0, 0), 170, Point(120, -100)) test_app.draw_intersection_points() test_app.run() |
Néhány tesztfuttatás eredményének képernyőképe alább látható.

Ebben a bejegyzésben a math, cmath és itertools modulok függvényéit használtuk, valamint a dataclasses modult, hogy definiáljunk egy adatosztályt paraméteres dataclass dekorátorral. Az adatosztályok létrehozásához típusannotációk is szükségesek, amelyeket típushelyettesítőkkel, illetve a typing modul igénybevételével biztosítottunk. Mindezekről a a Python tudásépítés lépésről lépésre című e-könyv „Készétel fogyasztás – a szabványos könyvtár moduljainak használata” és „Különleges osztálydefiníciók” fejezetekben lehet részletesen olvasni. GUI alkalmazás fejlesztéséhez szükséges ismereteket pedig a „Grafikus felhasználói felület készítése” című részből lehet elsajátítani.