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

AI로 자동 설계 프로그램 만들기 - N단 Shaft

by ToolBOX01 2026. 5. 30.
반응형

■ 5단이하의 Shaft 만들기: 

"여러 개의 원기둥을 X축 방향으로 기차처럼 직렬 연결하고, 앞 단계 원기둥의 길이가 바뀌면 뒤쪽 원기둥들이 알아서 빈틈없이 밀리거나 당겨지도록 자동 정렬(연쇄 반응)하는 것"입니다. 사용자가 GUI 창에서 직경과 길이를 입력하며 단계별로 정밀한 모델링을 제어할 수 있도록 설계되어 있습니다.

※ 핵심 기능 (작동 원리)

  • 가변형 직렬 배치 (최대 5개): 사용자가 설정한 개수(1~5개)만큼 원기둥이 X축 방향으로 이어 붙여집니다.
  • 연쇄 반응형 위치 갱신 (Cascade Offset): * 예를 들어 1번, 2번, 3번 원기둥이 붙어 있을 때, 중간에 있는 2번 원기둥의 길이를 늘리면 3번 원기둥이 그만큼 자동으로 X축 방향으로 평행이동(AttachmentOffset)합니다. - 모델이 깨지거나 틈이 벌어지는 것을 방지하는 핵심 알고리즘입니다.
  • FreeCAD의 최신 버전 표준 규격에 맞추어 작성되었으며, GUI 라이브러리인 PySide6와 PySide2 환경 모두에서 오류 없이 작동하도록 예외 처리가 되어 있습니다.

 

 

※ 파이썬 코드 

"""
FreeCAD Part Design - 가변 원기둥 생성/수정 GUI 매크로 (저장/불러오기 완전 대응)
- 파일 저장 후 다시 열어도 기존에 생성된 원기둥들을 자동 감지하여 수정 모드로 전환됩니다.
- FreeCAD 1.x (PySide6 / PySide2 범용) 대응
"""

import FreeCAD
import FreeCADGui
import Part
import Sketcher

try:
    from PySide6 import QtWidgets, QtCore
except ImportError:
    from PySide2 import QtWidgets, QtCore

# ─────────────────────────────────────────────────────────────────────────────
# 전역 상태 (리스트 구조 유지)
# ─────────────────────────────────────────────────────────────────────────────
_state = {
    "doc":      None,
    "body":     None,
    "xy_plane": None,
    "count":    0,         
    "cylinders": []        
}

# ─────────────────────────────────────────────────────────────────────────────
# [신규] 저장된 파일 모델 역추적 및 로드 함수
# ─────────────────────────────────────────────────────────────────────────────
def _load_existing_cylinders():
    """현재 활성화된 문서에서 기존 매크로로 생성된 원기둥 구조를 역추적합니다."""
    doc = FreeCAD.ActiveDocument
    if not doc:
        return False
        
    body = doc.getObject("Body")
    if not body:
        return False

    # 기존 원기둥 규칙(Sketch_1, Revolution_1...)을 기반으로 데이터 복원
    idx = 0
    found_cylinders = []
    xy_plane = None

    while True:
        suffix = f"_{idx+1}"
        sk = doc.getObject(f"Sketch{suffix}")
        rv = doc.getObject(f"Revolution{suffix}")
        
        # 더 이상 연속된 원기둥 피처가 없으면 중단
        if not sk or not rv:
            break
            
        if idx == 0 and hasattr(sk, "Support") and sk.Support:
            xy_plane = sk.Support[0][0]
        elif idx == 0 and hasattr(sk, "AttachmentSupport") and sk.AttachmentSupport:
            xy_plane = sk.AttachmentSupport[0][0]

        # 치수 구속 인덱스 및 현재 값 역추적
        idx_len = -1
        idx_rad = -1
        length_val = 100.0
        diameter_val = 50.0

        for c_idx, const in enumerate(sk.Constraints):
            if const.Type == "DistanceX":
                idx_len = c_idx
                length_val = float(const.Value)
            elif const.Type == "DistanceY":
                idx_rad = c_idx
                diameter_val = float(const.Value) * 2.0 # 반지름을 지름으로 변환

        found_cylinders.append({
            "sketch": sk,
            "revolution": rv,
            "idx_len": idx_len,
            "idx_rad": idx_rad,
            "length": length_val,
            "diameter": diameter_val
        })
        idx += 1

    if found_cylinders:
        _state["doc"] = doc
        _state["body"] = body
        _state["xy_plane"] = xy_plane if xy_plane else _find_xy_plane(doc)
        _state["count"] = len(found_cylinders)
        _state["cylinders"] = found_cylinders
        return True
        
    return False

# ─────────────────────────────────────────────────────────────────────────────
# XY_Plane 탐색 Helper
# ─────────────────────────────────────────────────────────────────────────────
def _find_xy_plane(doc):
    for obj in doc.Objects:
        if obj.Name == "XY_Plane" or obj.Label == "XY_Plane":
            return obj
    origin = doc.getObject("Origin")
    if origin and hasattr(origin, "OriginFeatures"):
        for child in origin.OriginFeatures:
            if "XY" in child.Label:
                return child
    return None

# ─────────────────────────────────────────────────────────────────────────────
# 스케치 + Revolution 공통 빌더
# ─────────────────────────────────────────────────────────────────────────────
def _build_sketch_revolution(body, doc, idx, R, L, x_offset, xy_plane):
    name_suffix = f"_{idx+1}"
    sketch = body.newObject("Sketcher::SketchObject", f"Sketch{name_suffix}")
    doc.recompute()

    try:
        sketch.AttachmentSupport = (xy_plane, [""])
    except Exception:
        try:
            sketch.Support = (xy_plane, [""])
        except Exception:
            pass
    sketch.MapMode = "FlatFace"

    if x_offset != 0.0:
        sketch.AttachmentOffset = FreeCAD.Placement(
            FreeCAD.Vector(x_offset, 0, 0),
            FreeCAD.Rotation(0, 0, 0)
        )

    doc.recompute()

    sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(L,  0, 0)), False)
    sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(L,  0, 0), FreeCAD.Vector(L,  R, 0)), False)
    sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(L,  R, 0), FreeCAD.Vector(0,  R, 0)), False)
    sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(0,  R, 0), FreeCAD.Vector(0,  0, 0)), False)

    sketch.addConstraint(Sketcher.Constraint("Coincident", 0, 2, 1, 1))
    sketch.addConstraint(Sketcher.Constraint("Coincident", 1, 2, 2, 1))
    sketch.addConstraint(Sketcher.Constraint("Coincident", 2, 2, 3, 1))
    sketch.addConstraint(Sketcher.Constraint("Coincident", 3, 2, 0, 1))
    sketch.addConstraint(Sketcher.Constraint("Coincident", 0, 1, -1, 1)) 
    sketch.addConstraint(Sketcher.Constraint("Horizontal", 0))
    sketch.addConstraint(Sketcher.Constraint("Horizontal", 2))
    sketch.addConstraint(Sketcher.Constraint("Vertical",   1))
    sketch.addConstraint(Sketcher.Constraint("Vertical",   3))

    idx_len = sketch.ConstraintCount
    sketch.addConstraint(Sketcher.Constraint("DistanceX", 0, 1, 0, 2, L))

    idx_rad = sketch.ConstraintCount
    sketch.addConstraint(Sketcher.Constraint("DistanceY", 1, 1, 1, 2, R))

    doc.recompute()

    revolution = body.newObject("PartDesign::Revolution", f"Revolution{name_suffix}")
    revolution.Profile       = sketch
    revolution.ReferenceAxis = (sketch, ["H_Axis"])
    revolution.Angle         = 360.0
    try:
        revolution.Reversed = False
    except Exception:
        pass
    doc.recompute()

    return sketch, revolution, idx_len, idx_rad

# ─────────────────────────────────────────────────────────────────────────────
# 원기둥 순차 생성 함수
# ─────────────────────────────────────────────────────────────────────────────
def _create_next_cylinder(idx, diameter, length):
    doc = _state["doc"]
    body = _state["body"]
    xy_plane = _state["xy_plane"]
    
    if idx == 0 and not doc:
        doc = FreeCAD.newDocument("MultiCylinders")
        body = doc.addObject("PartDesign::Body", "Body")
        doc.recompute()
        xy_plane = _find_xy_plane(doc)
        _state["doc"] = doc
        _state["body"] = body
        _state["xy_plane"] = xy_plane
        _state["cylinders"] = []

    x_offset = 0.0
    for i in range(idx):
        x_offset += _state["cylinders"][i]["length"]

    R, L = diameter / 2.0, length
    sk, rv, idx_len, idx_rad = _build_sketch_revolution(body, doc, idx, R, L, x_offset, xy_plane)

    _state["cylinders"].append({
        "sketch": sk,
        "revolution": rv,
        "idx_len": idx_len,
        "idx_rad": idx_rad,
        "length": length,
        "diameter": diameter
    })
    
    _view_fit()
    FreeCAD.Console.PrintMessage(f"[Cyl-{idx+1}] 생성 완료\n")

# ─────────────────────────────────────────────────────────────────────────────
# 원기둥 수정 및 연쇄 반응 함수
# ─────────────────────────────────────────────────────────────────────────────
def _update_cylinder(target_idx, new_diameter, new_length):
    doc = _state["doc"]
    cyl = _state["cylinders"][target_idx]
    
    R, L = new_diameter / 2.0, new_length
    cyl["sketch"].setDatum(cyl["idx_len"], FreeCAD.Units.Quantity(f"{L} mm"))
    cyl["sketch"].setDatum(cyl["idx_rad"], FreeCAD.Units.Quantity(f"{R} mm"))
    cyl["length"] = new_length
    cyl["diameter"] = new_diameter

    for i in range(target_idx + 1, len(_state["cylinders"])):
        accumulated_offset = sum(_state["cylinders"][j]["length"] for j in range(i))
        sk_next = _state["cylinders"][i]["sketch"]
        sk_next.AttachmentOffset = FreeCAD.Placement(
            FreeCAD.Vector(accumulated_offset, 0, 0),
            FreeCAD.Rotation(0, 0, 0)
        )

    doc.recompute()
    _view_fit()
    FreeCAD.Console.PrintMessage(f"[Cyl-{target_idx+1}] 수정 완료\n")

def _view_fit():
    try:
        FreeCADGui.activeDocument().activeView().viewIsometric()
        FreeCADGui.SendMsgToActiveView("ViewFit")
    except Exception:
        pass

# ─────────────────────────────────────────────────────────────────────────────
# 동적 GUI 클래스 (저장 모델 인식 로직 탑재)
# ─────────────────────────────────────────────────────────────────────────────
class MultiCylinderDialog(QtWidgets.QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("가변형 원기둥 직렬 제어기")
        self.setFixedWidth(480)
        self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint)
            
        self.inputs = []  
        self.buttons = [] 
        self._build_ui()
        
        # [핵심] 창이 열릴 때 기존 모델이 열려있는지 체크
        self._check_and_load_existing()

    def _build_ui(self):
        self.root = QtWidgets.QVBoxLayout(self)
        self.root.setSpacing(8)
        self.root.setContentsMargins(16, 16, 16, 16)

        title = QtWidgets.QLabel("🔩 FreeCAD 가변 원기둥 자동 생성/수정 매크로")
        self._center(title)
        title.setStyleSheet("font-size:14px; font-weight:bold; color:#4FC3F7;")
        self.root.addWidget(title)

        # 개수 설정부
        self.cnt_row_layout = QtWidgets.QHBoxLayout()
        self.cnt_label = QtWidgets.QLabel("생성할 원기둥 개수 (1 ~ 5개):")
        self.cnt_label.setStyleSheet("font-weight: bold; color: #FFF59D;")
        self.spin_count = QtWidgets.QSpinBox()
        self.spin_count.setRange(1, 5)
        self.spin_count.setValue(3) 
        self.spin_count.setFixedHeight(28)
        self.spin_count.setStyleSheet("background:#243447; color:white; border:1px solid #455A64;")
        
        self.btn_apply_count = QtWidgets.QPushButton("개수 적용")
        self.btn_apply_count.setFixedHeight(28)
        self.btn_apply_count.setStyleSheet(self._btn("#00838F", "#00ACC1"))
        self.btn_apply_count.clicked.connect(self._on_apply_count)

        self.cnt_row_layout.addWidget(self.cnt_label)
        self.cnt_row_layout.addWidget(self.spin_count)
        self.cnt_row_layout.addWidget(self.btn_apply_count)
        self.root.addLayout(self.cnt_row_layout)
        
        self.root.addWidget(self._hline())

        self.dynamic_layout = QtWidgets.QVBoxLayout()
        self.root.addLayout(self.dynamic_layout)

        # 초기 필드 기본 3개 배치
        self._generate_dynamic_fields(3, is_load_mode=False)

        # 하단 상태창
        self.lbl_status = QtWidgets.QLabel("💡 [신규 문서] 개수 적용 후 1번부터 생성하세요.")
        self.lbl_status.setWordWrap(True)
        self.lbl_status.setStyleSheet("color:#B0BEC5; font-size:11px;")
        self.root.addWidget(self.lbl_status)

        btn_close = QtWidgets.QPushButton("닫  기")
        btn_close.setFixedHeight(32)
        btn_close.setStyleSheet(self._btn("#455A64", "#607D8B"))
        btn_close.clicked.connect(self.close)
        self.root.addWidget(btn_close)

        self.setStyleSheet(
            "QDialog        { background:#1A2535; color:#ECEFF1; }"
            "QLabel         { color:#ECEFF1; }"
            "QDoubleSpinBox  { background:#243447; color:#ECEFF1; border:1px solid #455A64; border-radius:4px; padding:3px; }"
        )

    def _check_and_load_existing(self):
        """기존 파일의 모델을 불러왔는지 확인하고 GUI를 수정모드로 강제 변환합니다."""
        if _load_existing_cylinders():
            existing_count = _state["count"]
            self.spin_count.setValue(existing_count)
            
            # 개수 설정 레이아웃 비활성화 (기존 모델의 개수를 임의로 바꾸지 못하게 고정)
            self.spin_count.setEnabled(False)
            self.btn_apply_count.setEnabled(False)
            
            # 기존 모델 데이터 기반으로 필드 재생성 및 수정 모드 활성화
            self._generate_dynamic_fields(existing_count, is_load_mode=True)
            self._msg(f"📂 기존 모델 복원 완료! 이미 생성된 {existing_count}개의 원기둥에 대한 [수정]만 가능합니다.", "#81C784")
        else:
            # 기존 파일에 원기둥이 없다면 클린 신규 생성 모드 유지
            _state["doc"] = None
            _state["cylinders"] = []

    def _generate_dynamic_fields(self, count, is_load_mode=False):
        while self.dynamic_layout.count():
            child = self.dynamic_layout.takeAt(0)
            if child.widget():
                child.widget().deleteLater()
            elif child.layout():
                self._clear_layout(child.layout())

        self.inputs = []
        self.buttons = []
        _state["count"] = count

        colors = ["#4FC3F7", "#FFB74D", "#AED581", "#F06292", "#BA68C8"]

        for i in range(count):
            sec_title = QtWidgets.QLabel(f"● 원기둥 {i+1} 설정" + (" (수정 가능)" if is_load_mode else ""))
            sec_title.setStyleSheet(f"color:{colors[i]}; font-weight:bold; font-size:12px; padding-top:4px;")
            self.dynamic_layout.addWidget(sec_title)

            # 불러오기 모드라면 저장되어 있던 기존 치수를 박스에 기본값으로 바인딩
            d_init = _state["cylinders"][i]["diameter"] if is_load_mode else (80.0 - i*10)
            l_init = _state["cylinders"][i]["length"] if is_load_mode else (120.0 - i*10)

            input_row = QtWidgets.QHBoxLayout()
            diam_spin = self._spin(1, 2000, d_init, "mm")
            len_spin = self._spin(1, 5000, l_init, "mm")
            
            input_row.addWidget(QtWidgets.QLabel("직경:"))
            input_row.addWidget(diam_spin)
            input_row.addSpacing(8)
            input_row.addWidget(QtWidgets.QLabel("길이:"))
            input_row.addWidget(len_spin)
            self.dynamic_layout.addLayout(input_row)
            self.inputs.append([diam_spin, len_spin])

            btn_row = QtWidgets.QHBoxLayout()
            if is_load_mode:
                # 불러온 파일일 경우: 생성은 완전히 막고 [수정]만 켜둠
                btn_create = self._make_btn(f"▶ 생성 불가능 (기존 객체)", "#263238", "#263238", enabled=False)
                btn_edit = self._make_btn(f"✏ 원기둥 {i+1} 수정", "#2E7D32", "#43A047", enabled=True)
            else:
                # 신규 파일일 경우: 기존의 순차 생성 흐름 유지
                is_first = (i == 0)
                btn_create = self._make_btn(f"▶ 원기둥 {i+1} 생성", "#1565C0", "#1E88E5", enabled=is_first)
                btn_edit = self._make_btn(f"✏ 원기둥 {i+1} 수정", "#2E7D32", "#43A047", enabled=False)
            
            btn_create.clicked.connect(lambda checked=False, idx=i: self._on_create_clicked(idx))
            btn_edit.clicked.connect(lambda checked=False, idx=i: self._on_edit_clicked(idx))
            
            btn_row.addWidget(btn_create)
            btn_row.addWidget(btn_edit)
            self.dynamic_layout.addLayout(btn_row)
            self.buttons.append([btn_create, btn_edit])
            
            if i < count - 1:
                self.dynamic_layout.addWidget(self._hline())

    def _clear_layout(self, layout):
        while layout.count():
            child = layout.takeAt(0)
            if child.widget():
                child.widget().deleteLater()
            elif child.layout():
                self._clear_layout(child.layout())

    # ── 이벤트 핸들러 ──────────────────────────────────────────────────
    def _on_apply_count(self):
        new_cnt = self.spin_count.value()
        self._generate_dynamic_fields(new_cnt, is_load_mode=False)
        _state["doc"] = None 
        _state["cylinders"] = []
        self._msg(f"💡 원기둥 개수가 {new_cnt}개로 설정되었습니다. 1번부터 생성하세요.", "#FFF59D")

    def _on_create_clicked(self, idx):
        d = self.inputs[idx][0].value()
        l = self.inputs[idx][1].value()
        try:
            _create_next_cylinder(idx, d, l)
            self.buttons[idx][0].setEnabled(False) 
            self.buttons[idx][1].setEnabled(True)  
            
            if idx + 1 < _state["count"]:
                self.buttons[idx+1][0].setEnabled(True)
                self._msg(f"✅ [Cyl-{idx+1}] 완료 -> 다음 단계를 진행하세요.", "#A5D6A7")
            else:
                self._msg(f"🎉 모든 원기둥({_state['count']}개) 조립체 생성이 끝났습니다! 저장 후 다시 열어도 연동됩니다.", "#81C784")
        except Exception as e:
            self._msg(f"❌ 원기둥 {idx+1} 생성 실패: {e}", "#EF9A9A")

    def _on_edit_clicked(self, idx):
        d = self.inputs[idx][0].value()
        l = self.inputs[idx][1].value()
        try:
            _update_cylinder(idx, d, l)
            msg_suffix = " (뒤쪽 원기둥 위치 연쇄 정렬됨)" if idx + 1 < len(_state["cylinders"]) else ""
            self._msg(f"✏ [Cyl-{idx+1}] 수정 완료: Ø{d} × L{l} mm{msg_suffix}", "#FFF59D")
        except Exception as e:
            self._msg(f"❌ 원기둥 {idx+1} 수정 실패: {e}", "#EF9A9A")

    def _msg(self, text, color="#B0BEC5"):
        self.lbl_status.setText(text)
        self.lbl_status.setStyleSheet(f"color:{color}; font-size:11px;")

    def _make_btn(self, label, base, hover, enabled=True):
        b = QtWidgets.QPushButton(label)
        b.setFixedHeight(34)
        b.setEnabled(enabled)
        b.setStyleSheet(self._btn(base, hover))
        return b

    @staticmethod
    def _center(lbl):
        try:
            lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
        except AttributeError:
            lbl.setAlignment(QtCore.Qt.AlignCenter)

    @staticmethod
    def _spin(mn, mx, val, suf):
        sb = QtWidgets.QDoubleSpinBox()
        sb.setRange(mn, mx)
        sb.setValue(val)
        sb.setSuffix(f"  {suf}")
        sb.setDecimals(1)
        sb.setSingleStep(5.0)
        sb.setFixedHeight(26)
        return sb

    @staticmethod
    def _hline():
        f = QtWidgets.QFrame()
        try:
            f.setFrameShape(QtWidgets.QFrame.Shape.HLine)
        except AttributeError:
            f.setFrameShape(QtWidgets.QFrame.HLine)
        f.setStyleSheet("color:#2E3F50;")
        return f

    @staticmethod
    def _btn(base, hover):
        return (f"QPushButton          {{ background:{base}; color:white; border:none;"
                f"                        border-radius:4px; font-size:11px; font-weight:bold; }}"
                f"QPushButton:hover    {{ background:{hover}; }}"
                f"QPushButton:pressed  {{ background:{base}; }}"
                f"QPushButton:disabled {{ background:#263238; color:#455A64; }}")

def main():
    for w in QtWidgets.QApplication.topLevelWidgets():
        if isinstance(w, MultiCylinderDialog):
            w.close() # 기존에 열린 창이 있다면 깔끔하게 닫고 새로 시작
    dlg = MultiCylinderDialog(FreeCADGui.getMainWindow())
    dlg.show()

if __name__ == "__main__":
    main()

 

참고 프로그램

 

 

 

 

 

by korealionkk@gmail.com

반응형