파이썬으로 Solar System 구현하기
개요
안녕하세요.
학원생 중 윤준오 학생(중2)이 만든 예제에서 영감을 얻어 제가 살짝 재구성한 예제입니다.
Python과 PyQt6를 활용해 태양계를 시뮬레이션해 보았습니다.
지구의 크기와 공전 주기를 1로 설정한 뒤, 실제 천문 데이터를 바탕으로 태양계 내 행성들의 상대적 비율을 정밀하게 반영하여 구현했습니다.
다만 태양의 경우 너무 압도적인 크기라 비율 (지구:태양 = 1:109) 을 임의로 조정하였으며 나머지 모든 행성들은 크기와 공전주기를 실제 비율로 반영하였습니다.
앱을 캡쳐한 화면입니다. 위젯의 크기가 변경되면 리사이징 되도록 코드가 작성되어 있습니다.
|
| [앱 캡쳐 이미지] |
태양계 시뮬레이션 소개
이 프로젝트는 행성 데이터 관리(planets.py), 핵심 로직(solar_system.py), 메인함수(main.py)의 3개 구조로 설계되었습니다.
각 파일을 같은 경로에 두고 main.py를 실행하면 됩니다.
하나의 파일에 코드를 모두 넣어도 동작 가능하지만 기능별로 파일을 분리해 코드를 짜는 것은 차후 프로젝트의 규모가 커졌을 때 유지보수의 용이성, 버그감소, 가독성 향상 등 여러 장점이 있습니다.
그럼 각 파이썬 소스코드를 살펴보고 설명드리도록 하겠습니다.
1. planets.py
각 행성의 물리적 특징을 딕셔너리 형태로 정의합니다.
단순 리스트가 아닌 키-값 쌍을 사용하여 코드의 가독성을 높였습니다.
from PyQt6.QtGui import QColor
Planets = {
'Sun': {
'size': 5, # real scale is 109.0
'color': QColor(255, 140, 0),
'speed': 0,
'angle': 0
},
'Mercury': {
'size': 0.38,
'orb_w': 60,
'orb_h': 54,
'color': QColor(160, 160, 160),
'speed': 4.15,
'angle': 0
},
'Venus': {
'size': 0.95,
'orb_w': 90,
'orb_h': 87,
'color': QColor(255, 200, 0),
'speed': 1.62,
'angle': 0
},
'Earth': {
'size': 1.0,
'orb_w': 130,
'orb_h': 128,
'color': QColor(0, 150, 255),
'speed': 1.0,
'angle': 0
},
'Mars': {
'size': 0.53,
'orb_w': 170,
'orb_h': 160,
'color': QColor(255, 50, 0),
'speed': 0.53,
'angle': 0
},
'Jupiter': {
'size': 11.2,
'orb_w': 250,
'orb_h': 245,
'color': QColor(200, 180, 150),
'speed': 0.08,
'angle': 0
},
'Saturn': {
'size': 9.4,
'orb_w': 330,
'orb_h': 320,
'color': QColor(220, 200, 100),
'speed': 0.03,
'angle': 0
},
'Uranus': {
'size': 4.0,
'orb_w': 400,
'orb_h': 395,
'color': QColor(150, 220, 255),
'speed': 0.01,
'angle': 0
},
'Neptune': {
'size': 3.9,
'orb_w': 460,
'orb_h': 455,
'color': QColor(50, 100, 255),
'speed': 0.006,
'angle': 0
}
}
주요 파라미터들
- size: 행성의 상대적 크기 (지구 = 1.0 기준, 태양은 너무 커 조정)
- orb_w / orb_h: 타원 궤도의 가로/세로 반경
- speed: 공전 속도 (숫자가 클수록 빠름)
- color: QColor를 이용한 행성 고유 색상
2. solar_system.py
이 파일은 삼각함수를 활용한 수학적 계산과 그래픽 렌더링을 담당합니다.
사실상 앱의 가장 중요한 부분이며, 행성들의 위치를 계산, 시각화하고 쓰레드를 통해 지속적으로 변경된 위치를 렌더링하는 기능이 구현되어 있습니다.
from PyQt6.QtCore import Qt, QRectF, QPointF
from PyQt6.QtGui import QPainter, QColor, QPen
from threading import Thread
import planets
import time
import math
class SolarSystem:
def __init__(self, w):
self.parent = w
self.rect = QRectF(w.rect())
self.center = self.rect.center()
# 행성 기본 크기 스케일
self.base_size = 6
self.planets = planets.Planets
# thread
self.run = True
self.t = Thread(target=self.threadFunc)
self.t.start()
def getPosition(self, deg, radius_w, radius_h, cx, cy):
rad = deg * math.pi / 180
dx = math.sin(rad) * radius_w
dy = -math.cos(rad) * radius_h
return QPointF(cx + dx, cy + dy)
def draw(self, qp):
qp.setRenderHint(QPainter.RenderHint.Antialiasing)
self.rect = QRectF(self.parent.rect())
self.center = self.rect.center()
gap = 40
max_radius = min(self.rect.width(), self.rect.height()) / 2 - gap
outer_orbit = max(
p.get('orb_w', 0)
for p in self.planets.values()
)
scale = max_radius / outer_orbit
# 궤도선
pen = QPen(QColor(80, 80, 80))
pen.setStyle(Qt.PenStyle.DashLine)
qp.setPen(pen)
qp.setBrush(Qt.BrushStyle.NoBrush)
for name, planet in self.planets.items():
if name == 'Sun':
continue
qp.drawEllipse(
self.center,
planet['orb_w'] * scale,
planet['orb_h'] * scale
)
# 행성
for name, planet in self.planets.items():
size = planet['size'] * self.base_size * scale
qp.setBrush(planet['color'])
qp.setPen(Qt.PenStyle.NoPen)
if name == 'Sun':
pos = self.center
else:
pos = self.getPosition(
planet['angle'],
planet['orb_w'] * scale,
planet['orb_h'] * scale,
self.center.x(),
self.center.y()
)
qp.drawEllipse(pos, size, size)
# 이름
qp.setPen(QColor(0, 0, 0))
x = pos.x() + size + 4
y = pos.y() - size - 4
qp.drawText(QPointF(x, y), name)
def threadFunc(self):
while self.run:
for planet in self.planets.values():
planet['angle'] += planet['speed']
self.parent.update()
time.sleep(0.02)
def close(self):
self.run = False
가장 주의 깊게 살펴보아야 할 두 가지 핵심 함수는 다음과 같습니다.
① 좌표 계산 함수: getPosition()
행성은 원형 또는 타원형 궤도를 따라 움직입니다. 따라서 화면상의 행성 위치 (x,y) 좌표를 구하기 위해 삼각함수(sin,cos)를 활용합니다.
만약 아래 그램에서 행성의 각도가 20도 라면 이를 라디안(θ)로 변환합니다. 파이썬의 sin, cos 함수는 전달인자로 라디안각을 사용하기 때문입니다.
이제 sin(θ) = 높이(x) / 빗변(r) 이므로, 반지름인 빗변(r)의 길이를 좌변에 곱하면 높이(x)를 알아낼 수 있습니다.
마친가지로, cos(θ) = 밑변(y) / 빗변(r) 이므로 반지름 빗변(r)을 좌변에 곱해 밑변(y)를 찾아냅니다.
이제 중심점의 값에 위에서 구해진 x, y를 더하면 20도 각도에서의 행성위치를 찾아내게 됩니다.😀
![]() |
| [sin, cos를 이용한 위치 찾기] |
작동 원리
- 입력받은 각도(deg)를 라디안(rad) 단위로 변환
- x=sin(rad)×반경, y=−cos(rad)×반경 공식을 사용
- 중심점(cx, cy)을 더해 화면 중앙을 기준으로 행성을 배치
아날로그 시계를 그리는 원리와 동일합니다.
② 실시간 애니메이션: threadFunc()
메인 창이 멈추지 않고 행성들이 계속 움직이게 하려면 쓰레드가 필수입니다.
쓰레드는 '실타래' 라는 뜻으로 앱에서는 실행흐름이라 이해하면 쉽습니다.
메인함수가 동작하는 흐름에서 별도의 실행흐름을 하나 만들어 (계속 동작해야 하므로), 그 쓰레드에서 행성의 각도를 증가시켜줍니다.
모든 행성의 angle 값에 speed를 더해 각도를 변경, 실제 행성의 이동을 구현합니다.
이제 그 실행흐름은 무한루프를 돌며 계속해서 행성의 움직임을 구현합니다.
마지막에 self.parent.update()를 호출, main.py의 paintEvent가 다시 실행되도록 유도합니다.
만약 메인쓰레드 (앱생성시 기본생성) 에서 위 행성의 이동을 구현한다면 무한루프에 빠져 앱이 정지되어 '응답없음 😧' 상태로 빠져 멈추게 됩니다.
3. main.py
실제 메인함수 역할이며, Qt의 앱과 위젯 객체를 이용, 사용자에게 보여지는 창을 생성합니다.
from PyQt6.QtWidgets import QApplication, QWidget
from PyQt6.QtGui import QPainter
from solar_system import SolarSystem
import sys
class Window(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle('Ocean Coding School')
self.ss = SolarSystem(self)
def paintEvent(self, e):
qp = QPainter()
qp.begin(self)
self.ss.draw(qp)
qp.end()
def closeEvent(self, e):
self.ss.close()
if __name__ == '__main__':
app = QApplication(sys.argv)
w = Window()
w.show()
sys.exit(app.exec())
paintEvent() 함수는 QWidet에서 윈도우가 그려질 때마다 호출되는 함수입니다.
다면 이 곳에서 그리는 것이 아니라 QPainter의 객체를 생성하여 SolarSystem 객체에 전달하고 그림을 그리게 합니다. (코드 분산)
closeEvent() 함수는 사용자가 창을 닫을 때 SolarSystem의 close() 함수를 호출, 실행 중인 스레드를 안전하게 중단시켜 메모리 누수나 오류를 방지합니다.
이상으로 모든 설명을 마칩니다. 궁금한 점은 댓글 남겨주세요~😊
마지막으로 이 게시물의 모든 내용이 이해가 된다면, 지구나 다른 행성들의 위성 (예를 들면 달) 을 추가해 보는것도 가능하지 않을까요?
감사합니다.


댓글
댓글 쓰기