Solar System (파이썬으로 태양계 그리기)

개요

안녕하세요.

학원생 중 준오 학생(중2)이 만든 예제에서 영감을 얻어 제가 살짝 재구성한 예제입니다.

파이썬과 PyQt6를 이용해 태양계 행성들을 그려보았습니다.




[앱 캡쳐 이미지]

태양계 시뮬레이션 프로젝트 소개

이 프로젝트는 행성 데이터 관리(planets.py), 핵심 로직(solar_system.py), 메인함수(main.py)의 3단계 구조로 설계되었습니다.

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)를 활용합니다.

행성의 회전 각도와 반지름의 길이를 이용해 행성의 위치를 구합니다.

[sin, cos를 이용한 위치 찾기]


작동 원리

  • 입력받은 각도(deg)를 라디안(rad) 단위로 변환

  • x=sin(rad)×반경, y=−cos(rad)×반경 공식을 사용
  • 중심점(cx, cy)을 더해 화면 중앙을 기준으로 행성을 배치

아날로그 시계를 그리는 원리와 동일합니다.


② 실시간 애니메이션: threadFunc()

메인 창이 멈추지 않고 행성들이 계속 움직이게 하려면 쓰레드가 필수입니다.

이 함수는 생성자함수에서 생성된 쓰레드가 실행하는 함수이며, 무한루프를 돌며 계속해서 행성의 움직임을 구현합니다.

모든 행성의 angle 값에 speed를 더해 각도를 변경, 실제 행성의 이동을 구현합니다.

마지막에 self.parent.update()를 호출, main.py의 paintEvent가 다시 실행되도록 유도합니다.


3. main.py

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())

실제 메인함수 역할이며, Qt의 앱과 위젯 객체를 이용, 사용자에게 보여지는 창을 생성합니다.

paintEvent() 함수는 QWidet에서 윈도우가 그려질 때마다 호출되는 함수입니다.

다면 이 곳에서 그리는 것이 아니라 QPainter의 객체를 생성하여 SolarSystem 객체에 전달하고 그림을 그리게 합니다. (코드 분산)

closeEvent() 함수는 사용자가 창을 닫을 때 SolarSystem의 close() 함수를 호출, 실행 중인 스레드를 안전하게 중단시켜 메모리 누수나 오류를 방지합니다.


이상으로 모든 설명을 마칩니다.

감사합니다.

댓글

이 블로그의 인기 게시물

Qt Designer 설치하기

파이썬을 활용한 PID 제어기 GUI 구현

PyQt5 기반 동영상 플레이어앱 만들기