본문 바로가기
  • You find inspiration to create your own path !
업무 자동화/FreeCAD

관성모멘트(Moment of Inertia) 계산하기

by ToolBOX01 2026. 6. 6.
반응형

■ 관성모멘트(Moment of Inertia) 

3D 모델링 프로그램(CAD)이나 물리 엔진에서 물체의 관성모멘트(Moment of Inertia)를 계산할 때, 기본적으로 무게중심(Center of Gravity, CG / Center of Mass)을 기준으로 구하는 것이 표준입니다.

물리적 고유 특성: 물체가 외부 힘을 받지 않고 공간에서 자유롭게 회전할 때(예: 우주 공간이나 공중에서 회전할 때), 물체는 자연스럽게 무게중심을 축으로 회전합니다. 따라서 무게중심 기준의 관성모멘트가 물체의 고유한 회전 저항을 나타내는 가장 순수한 지표가 됩니다.

계산의 기준점: CAD 프로그램(Creo, CATIA, SolidWorks 등)에서 질량 특성(Mass Properties)을 조회하면, 대개 무게중심을 원점으로 정렬한 좌표계(Principal Coordinate System) 기준의 관성모멘트($I_{xx}, I_{yy}, I_{zz}$)를 가장 먼저 보여줍니다.


두개의 모델(Body) 형상이 동일한지 판별 해주는 프로그램

 

코드

"""
compare_shapes.py
두 개의 Body를 선택하여 동일 형상 여부를 YES / NO 로 표시하는 FreeCAD 매크로
FreeCAD 1.1.1 | PySide6
"""

import FreeCAD as App
import FreeCADGui as Gui
import Mesh
import math

from PySide6.QtWidgets import (
    QDialog, QVBoxLayout, QHBoxLayout, QLabel,
    QPushButton, QComboBox, QFrame, QSizePolicy
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont


# ──────────────────────────────────────────────
#  형상 비교 로직
# ──────────────────────────────────────────────

def is_identical_shape(body1, body2, tol=1e-5):
    shape1 = body1.Shape if hasattr(body1, "Shape") else body1
    shape2 = body2.Shape if hasattr(body2, "Shape") else body2

    # 1단계: 부피 · 표면적
    if not math.isclose(shape1.Volume, shape2.Volume, rel_tol=tol):
        return False, f"부피 불일치\n  {shape1.Volume:.4f}  vs  {shape2.Volume:.4f}"

    if not math.isclose(shape1.Area, shape2.Area, rel_tol=tol):
        return False, f"표면적 불일치\n  {shape1.Area:.4f}  vs  {shape2.Area:.4f}"

    # 2단계: 토폴로지 개수
    if len(shape1.Faces) != len(shape2.Faces):
        return False, f"면 개수 불일치  ({len(shape1.Faces)} vs {len(shape2.Faces)})"

    if len(shape1.Edges) != len(shape2.Edges):
        return False, f"모서리 개수 불일치  ({len(shape1.Edges)} vs {len(shape2.Edges)})"

    if len(shape1.Vertexes) != len(shape2.Vertexes):
        return False, f"꼭짓점 개수 불일치  ({len(shape1.Vertexes)} vs {len(shape2.Vertexes)})"

    # 3단계: 관성 모멘트 (대각 성분 정렬 비교)
    m1 = shape1.MatrixOfInertia
    m2 = shape2.MatrixOfInertia
    moments1 = sorted([m1.A11, m1.A22, m1.A33])
    moments2 = sorted([m2.A11, m2.A22, m2.A33])
    for a, b in zip(moments1, moments2):
        if not math.isclose(a, b, rel_tol=tol):
            return False, f"관성 모멘트 불일치\n  {moments1}\n  vs\n  {moments2}"

    return True, "부피 · 표면적 · 토폴로지 · 관성 모멘트\n모든 항목 일치"


# ──────────────────────────────────────────────
#  GUI
# ──────────────────────────────────────────────

class CompareDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("형상 비교")
        self.setMinimumWidth(380)
        self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint)
        self._build_ui()
        self._populate_combos()

    # ── UI 구성 ──────────────────────────────
    def _build_ui(self):
        root = QVBoxLayout(self)
        root.setSpacing(12)
        root.setContentsMargins(16, 16, 16, 16)

        # 타이틀
        title = QLabel("두 Body 형상 비교")
        title.setAlignment(Qt.AlignCenter)
        f = QFont(); f.setPointSize(12); f.setBold(True)
        title.setFont(f)
        root.addWidget(title)

        root.addWidget(self._hline())

        # Body 선택 콤보
        def combo_row(label_text):
            row = QHBoxLayout()
            lbl = QLabel(label_text)
            lbl.setFixedWidth(60)
            cb = QComboBox()
            cb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
            row.addWidget(lbl)
            row.addWidget(cb)
            return row, cb

        row1, self.cb1 = combo_row("Body A")
        row2, self.cb2 = combo_row("Body B")
        root.addLayout(row1)
        root.addLayout(row2)

        # 비교 버튼
        self.btn_compare = QPushButton("비교하기")
        self.btn_compare.setFixedHeight(36)
        self.btn_compare.clicked.connect(self._on_compare)
        root.addWidget(self.btn_compare)

        root.addWidget(self._hline())

        # 결과 표시 영역
        self.lbl_result = QLabel("—")
        self.lbl_result.setAlignment(Qt.AlignCenter)
        self.lbl_result.setWordWrap(True)
        f2 = QFont(); f2.setPointSize(28); f2.setBold(True)
        self.lbl_result.setFont(f2)
        self.lbl_result.setFixedHeight(70)
        root.addWidget(self.lbl_result)

        self.lbl_detail = QLabel("")
        self.lbl_detail.setAlignment(Qt.AlignCenter)
        self.lbl_detail.setWordWrap(True)
        root.addWidget(self.lbl_detail)

        root.addWidget(self._hline())

        # 닫기
        btn_close = QPushButton("닫기")
        btn_close.clicked.connect(self.close)
        root.addWidget(btn_close)

    def _hline(self):
        line = QFrame()
        line.setFrameShape(QFrame.HLine)
        line.setFrameShadow(QFrame.Sunken)
        return line

    # ── 콤보 채우기 ─────────────────────────
    def _populate_combos(self):
        doc = App.activeDocument()
        if doc is None:
            return

        bodies = [
            obj for obj in doc.Objects
            if obj.TypeId == "PartDesign::Body"
        ]

        for cb in (self.cb1, self.cb2):
            cb.clear()
            for b in bodies:
                cb.addItem(b.Label, b.Name)  # 표시=Label, 데이터=Name

        # 기본값: 선택된 객체가 있으면 먼저 세팅
        sel = [s.Object for s in Gui.Selection.getSelection()]
        if len(sel) >= 1:
            self._set_combo(self.cb1, sel[0].Name)
        if len(sel) >= 2:
            self._set_combo(self.cb2, sel[1].Name)

    def _set_combo(self, cb, name):
        for i in range(cb.count()):
            if cb.itemData(i) == name:
                cb.setCurrentIndex(i)
                return

    # ── 비교 실행 ────────────────────────────
    def _on_compare(self):
        doc = App.activeDocument()
        if doc is None:
            self._show_error("열린 문서가 없습니다.")
            return

        name1 = self.cb1.currentData()
        name2 = self.cb2.currentData()

        if not name1 or not name2:
            self._show_error("Body를 선택해 주세요.")
            return

        if name1 == name2:
            self._show_error("동일한 Body입니다.")
            return

        obj1 = doc.getObject(name1)
        obj2 = doc.getObject(name2)

        if obj1 is None or obj2 is None:
            self._show_error("객체를 찾을 수 없습니다.")
            return

        try:
            result, detail = is_identical_shape(obj1, obj2)
        except Exception as e:
            self._show_error(f"비교 중 오류:\n{e}")
            return

        if result:
            self.lbl_result.setText("✔  YES")
            self.lbl_result.setStyleSheet("color: #2ecc71;")
        else:
            self.lbl_result.setText("✘  NO")
            self.lbl_result.setStyleSheet("color: #e74c3c;")

        self.lbl_detail.setText(detail)

    def _show_error(self, msg):
        self.lbl_result.setText("—")
        self.lbl_result.setStyleSheet("color: gray;")
        self.lbl_detail.setText(msg)


# ──────────────────────────────────────────────
#  진입점
# ──────────────────────────────────────────────

def main():
    mw = Gui.getMainWindow()
    dlg = CompareDialog(mw)
    dlg.show()


main()

 

프로그램 실행 결과


유사도 평가 프로그램

"두 개의 3D Body(형상) 유사도 비교 및 분석 매크로" 프로그램입니다. 기준좌표계가 달라도 형상 자체의 고유 기하학적 특성을 추출하여 두 모델이 얼마나 닮았는지 종합 점수(%) 및 항목별 매칭률로 시각화해 주는 훌륭한 자동화 도구입니다.

1. 핵심 기능 요약

  • 좌표 독립적 형상 비교: 두 Body가 공간상에서 서로 다른 위치에 있거나 회전되어 있어도, 기하학적 불변 특성들을 추출하여 형상 자체만 비교합니다.
  • 대화형 GUI 제공 (PySide6): 사용자가 콤보박스를 통해 직관적으로 Body A와 Body B를 선택하거나, FreeCAD 화면에서 객체를 미리 선택한 상태로 매크로를 실행하면 자동으로 매칭해 줍니다.
  • 실시간 시각화 (Progress Bar & Color Coding): 비교 결과(0~100%)에 따라 안전(초록색), 경고(노란색), 불일치(빨간색)로 직관적인 프로그레스 바와 텍스트 색상을 업데이트합니다.

2. 세부 알고리즘 및 분석 항목 (중요 로직)

코드 내부의 compare_shapes_detail 함수는 앞서 다루었던 기하학적 필터링 단계를 정확하게 반영하고 있습니다.

① 1차 기하학적 수치 및 위상(Topology) 비교

  • 부피 (Volume) & 표면적 (Area): 위치와 무관한 가장 확실한 척도입니다. 소수점 이하의 미세한 수치 차이를 비율 계산식(_similarity_pct)으로 점수화합니다.
  • 위상 요소 개수: 면(Faces), 모서리(Edges), 꼭짓점(Vertexes)의 개수를 카운트합니다. 개수가 완벽히 일치하면 100.0점, 다르면 개수 비율에 따라 감점합니다.

② 2차 주 관성모멘트 정렬 비교 (회전 보정)

  • MatrixOfInertia 활용: shape.MatrixOfInertia를 호출하여 두 모델의 무게중심 기준 관성 텐서 행렬을 가져옵니다.
  • 정렬 정규화 (sorted): 두 모델이 회전되어 있으면 축 성분($I_{xx}, I_{yy}, I_{zz}$)의 순서가 바뀔 수 있습니다. 코드에서는 이를 sorted([m.A11, m.A22, m.A33]) 처리를 통해 크기순($I_1, I_2, I_3$)으로 강제 정렬하여 방향이 달라도 완벽하게 정밀 매칭을 수행할 수 있도록 설계되었습니다.

3. UI 및 사용자 경험(UX) 요소 분석

  • WindowStaysOnTopHint 설정: self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint) 코드가 적용되어 있어, FreeCAD 메인 화면 뒤로 매크로 창이 숨지 않고 항상 위에 유지되므로 작업하기 편리합니다.
  • 유연한 예외 처리 (_reset): 문서가 열려있지 않거나, 동일한 Body를 선택했거나, 연산 중 에러가 발생하면 비정상 종료(Crash)되는 대신 GUI 하단에 안전하게 메시지를 출력하도록 방어적으로 코딩되어 있습니다.

 

코드

"""
compare_shapes.py
두 개의 Body를 선택하여 형상 유사도(%)를 표시하는 FreeCAD 매크로
FreeCAD 1.1.1 | PySide6
"""

import FreeCAD as App
import FreeCADGui as Gui
import math

from PySide6.QtWidgets import (
    QDialog, QVBoxLayout, QHBoxLayout, QLabel,
    QPushButton, QComboBox, QFrame, QSizePolicy,
    QProgressBar
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont, QColor


# ──────────────────────────────────────────────
#  형상 비교 로직  → 항목별 점수 반환
# ──────────────────────────────────────────────

def _similarity_pct(v1, v2, tol=1e-9):
    """두 수치의 유사도 0~100 반환 (비율 차이 기반)"""
    denom = max(abs(v1), abs(v2), tol)
    diff  = abs(v1 - v2) / denom          # 0 = 동일, 1 = 100 % 차이
    return max(0.0, 100.0 * (1.0 - diff))


def compare_shapes_detail(body1, body2):
    """
    각 항목의 유사도(0~100)와 설명을 담은 리스트를 반환.
    반환: [ {name, score, detail}, ... ]
    """
    shape1 = body1.Shape if hasattr(body1, "Shape") else body1
    shape2 = body2.Shape if hasattr(body2, "Shape") else body2

    results = []

    # ── 1. 부피 ──────────────────────────────
    v1, v2 = shape1.Volume, shape2.Volume
    score = _similarity_pct(v1, v2)
    results.append({
        "name"  : "부피",
        "score" : score,
        "detail": f"{v1:.4f}  vs  {v2:.4f}"
    })

    # ── 2. 표면적 ─────────────────────────────
    a1, a2 = shape1.Area, shape2.Area
    score = _similarity_pct(a1, a2)
    results.append({
        "name"  : "표면적",
        "score" : score,
        "detail": f"{a1:.4f}  vs  {a2:.4f}"
    })

    # ── 3. 면(Face) 개수 ──────────────────────
    f1, f2 = len(shape1.Faces), len(shape2.Faces)
    score = 100.0 if f1 == f2 else _similarity_pct(f1, f2)
    results.append({
        "name"  : "면 개수",
        "score" : score,
        "detail": f"{f1}  vs  {f2}"
    })

    # ── 4. 모서리(Edge) 개수 ─────────────────
    e1, e2 = len(shape1.Edges), len(shape2.Edges)
    score = 100.0 if e1 == e2 else _similarity_pct(e1, e2)
    results.append({
        "name"  : "모서리 개수",
        "score" : score,
        "detail": f"{e1}  vs  {e2}"
    })

    # ── 5. 꼭짓점(Vertex) 개수 ───────────────
    vx1, vx2 = len(shape1.Vertexes), len(shape2.Vertexes)
    score = 100.0 if vx1 == vx2 else _similarity_pct(vx1, vx2)
    results.append({
        "name"  : "꼭짓점 개수",
        "score" : score,
        "detail": f"{vx1}  vs  {vx2}"
    })

    # ── 6~8. 관성 모멘트 Ixx / Iyy / Izz ────
    m1 = shape1.MatrixOfInertia
    m2 = shape2.MatrixOfInertia
    ixx1, iyy1, izz1 = sorted([m1.A11, m1.A22, m1.A33])
    ixx2, iyy2, izz2 = sorted([m2.A11, m2.A22, m2.A33])

    for label, val1, val2 in (
        ("관성 모멘트 I₁", ixx1, ixx2),
        ("관성 모멘트 I₂", iyy1, iyy2),
        ("관성 모멘트 I₃", izz1, izz2),
    ):
        score = _similarity_pct(val1, val2)
        results.append({
            "name"  : label,
            "score" : score,
            "detail": f"{val1:.4e}  vs  {val2:.4e}"
        })

    return results


def overall_score(item_list):
    """항목 리스트의 단순 평균 점수"""
    if not item_list:
        return 0.0
    return sum(it["score"] for it in item_list) / len(item_list)


# ──────────────────────────────────────────────
#  색상 헬퍼
# ──────────────────────────────────────────────

def score_color(pct):
    """점수에 따라 RGB 색상 문자열 반환 (빨강 → 노랑 → 초록)"""
    if pct >= 99.999:
        return "#2ecc71"   # 초록
    elif pct >= 70:
        r = int(255 * (1 - (pct - 70) / 30))
        return f"rgb({r}, 220, 80)"
    else:
        return f"rgb(231, {int(180 * pct / 70)}, 60)"


# ──────────────────────────────────────────────
#  GUI
# ──────────────────────────────────────────────

class CompareDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("형상 유사도 비교")
        self.setMinimumWidth(420)
        self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint)
        self._build_ui()
        self._populate_combos()

    # ── UI 구성 ──────────────────────────────
    def _build_ui(self):
        root = QVBoxLayout(self)
        root.setSpacing(10)
        root.setContentsMargins(16, 16, 16, 16)

        # 타이틀
        title = QLabel("형상 유사도 비교")
        title.setAlignment(Qt.AlignCenter)
        f = QFont(); f.setPointSize(12); f.setBold(True)
        title.setFont(f)
        root.addWidget(title)
        root.addWidget(self._hline())

        # Body 선택 콤보
        def combo_row(label_text):
            row = QHBoxLayout()
            lbl = QLabel(label_text)
            lbl.setFixedWidth(60)
            cb = QComboBox()
            cb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
            row.addWidget(lbl)
            row.addWidget(cb)
            return row, cb

        row1, self.cb1 = combo_row("Body A")
        row2, self.cb2 = combo_row("Body B")
        root.addLayout(row1)
        root.addLayout(row2)

        # 비교 버튼
        self.btn_compare = QPushButton("비교하기")
        self.btn_compare.setFixedHeight(36)
        self.btn_compare.clicked.connect(self._on_compare)
        root.addWidget(self.btn_compare)
        root.addWidget(self._hline())

        # ── 종합 유사도 ──────────────────────
        lbl_total_title = QLabel("종합 유사도")
        lbl_total_title.setAlignment(Qt.AlignCenter)
        root.addWidget(lbl_total_title)

        self.lbl_total_pct = QLabel("— %")
        self.lbl_total_pct.setAlignment(Qt.AlignCenter)
        fTotal = QFont(); fTotal.setPointSize(26); fTotal.setBold(True)
        self.lbl_total_pct.setFont(fTotal)
        root.addWidget(self.lbl_total_pct)

        self.bar_total = QProgressBar()
        self.bar_total.setRange(0, 100)
        self.bar_total.setValue(0)
        self.bar_total.setTextVisible(False)
        self.bar_total.setFixedHeight(18)
        root.addWidget(self.bar_total)

        root.addWidget(self._hline())

        # ── 항목별 결과 ──────────────────────
        lbl_items_title = QLabel("항목별 유사도")
        lbl_items_title.setAlignment(Qt.AlignCenter)
        root.addWidget(lbl_items_title)

        self.item_widgets = []   # (name_lbl, bar, pct_lbl, detail_lbl)
        ITEM_NAMES = [
            "부피", "표면적", "면 개수", "모서리 개수",
            "꼭짓점 개수", "관성 모멘트 I₁", "관성 모멘트 I₂", "관성 모멘트 I₃"
        ]
        for name in ITEM_NAMES:
            # 이름 + 퍼센트 한 줄
            hrow = QHBoxLayout()
            name_lbl = QLabel(name)
            name_lbl.setFixedWidth(130)
            pct_lbl  = QLabel("—")
            pct_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
            pct_lbl.setFixedWidth(60)
            bar = QProgressBar()
            bar.setRange(0, 100)
            bar.setValue(0)
            bar.setTextVisible(False)
            bar.setFixedHeight(14)
            hrow.addWidget(name_lbl)
            hrow.addWidget(bar)
            hrow.addWidget(pct_lbl)
            root.addLayout(hrow)

            # 상세값 (작은 글씨)
            detail_lbl = QLabel("")
            detail_lbl.setAlignment(Qt.AlignCenter)
            detail_lbl.setStyleSheet("color: #888888; font-size: 10px;")
            root.addWidget(detail_lbl)

            self.item_widgets.append((name_lbl, bar, pct_lbl, detail_lbl))

        root.addWidget(self._hline())

        # 닫기
        btn_close = QPushButton("닫기")
        btn_close.clicked.connect(self.close)
        root.addWidget(btn_close)

    def _hline(self):
        line = QFrame()
        line.setFrameShape(QFrame.HLine)
        line.setFrameShadow(QFrame.Sunken)
        return line

    # ── 콤보 채우기 ─────────────────────────
    def _populate_combos(self):
        doc = App.activeDocument()
        if doc is None:
            return

        bodies = [obj for obj in doc.Objects if obj.TypeId == "PartDesign::Body"]

        for cb in (self.cb1, self.cb2):
            cb.clear()
            for b in bodies:
                cb.addItem(b.Label, b.Name)

        sel = [s.Object for s in Gui.Selection.getSelection()]
        if len(sel) >= 1:
            self._set_combo(self.cb1, sel[0].Name)
        if len(sel) >= 2:
            self._set_combo(self.cb2, sel[1].Name)

    def _set_combo(self, cb, name):
        for i in range(cb.count()):
            if cb.itemData(i) == name:
                cb.setCurrentIndex(i)
                return

    # ── 비교 실행 ────────────────────────────
    def _on_compare(self):
        doc = App.activeDocument()
        if doc is None:
            self._reset("열린 문서가 없습니다."); return

        name1 = self.cb1.currentData()
        name2 = self.cb2.currentData()
        if not name1 or not name2:
            self._reset("Body를 선택해 주세요."); return
        if name1 == name2:
            self._reset("동일한 Body를 선택했습니다."); return

        obj1 = doc.getObject(name1)
        obj2 = doc.getObject(name2)
        if obj1 is None or obj2 is None:
            self._reset("객체를 찾을 수 없습니다."); return

        try:
            items = compare_shapes_detail(obj1, obj2)
        except Exception as e:
            self._reset(f"오류: {e}"); return

        total = overall_score(items)
        color = score_color(total)

        # 종합 점수 업데이트
        self.lbl_total_pct.setText(f"{total:.1f} %")
        self.lbl_total_pct.setStyleSheet(f"color: {color};")
        self.bar_total.setValue(int(total))
        self.bar_total.setStyleSheet(
            f"QProgressBar::chunk {{ background-color: {color}; border-radius: 4px; }}"
        )

        # 항목별 업데이트
        for idx, item in enumerate(items):
            if idx >= len(self.item_widgets):
                break
            _, bar, pct_lbl, detail_lbl = self.item_widgets[idx]
            sc = item["score"]
            c  = score_color(sc)
            bar.setValue(int(sc))
            bar.setStyleSheet(
                f"QProgressBar::chunk {{ background-color: {c}; border-radius: 3px; }}"
            )
            pct_lbl.setText(f"{sc:.1f} %")
            pct_lbl.setStyleSheet(f"color: {c};")
            detail_lbl.setText(item["detail"])

    def _reset(self, msg=""):
        self.lbl_total_pct.setText("— %")
        self.lbl_total_pct.setStyleSheet("color: gray;")
        self.bar_total.setValue(0)
        self.bar_total.setStyleSheet("")
        for _, bar, pct_lbl, detail_lbl in self.item_widgets:
            bar.setValue(0)
            bar.setStyleSheet("")
            pct_lbl.setText("—")
            pct_lbl.setStyleSheet("color: gray;")
            detail_lbl.setText(msg if _ == self.item_widgets[0][0] else "")
        if msg:
            self.item_widgets[0][3].setText(msg)


# ──────────────────────────────────────────────
#  진입점
# ──────────────────────────────────────────────

def main():
    mw = Gui.getMainWindow()
    dlg = CompareDialog(mw)
    dlg.show()

main()

 

프로그램 실행 화면

 

 

주의

미러(Mirror, 대칭) 형상 판별:
현재 알고리즘(부피, 표면적, 개수, 주 관성모멘트 크기)은 오른손잡이용 부품과 왼손잡이용(대칭) 부품을 구별하지 못하고 100% 동일하다고 판단합니다. 만약 대칭 부품까지 잡아내야 한다면 [4단계]에 주축의 방향성(행렬식 기하 연산)이나 정점의 외적 방향 검증을 추가하면 완벽해집니다.

 

by korealionkk@gmail.com

반응형