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

AI로 자동 설계 프로그램 만들기 - N단 Shaft (업데이트)

by ToolBOX01 2026. 6. 1.
반응형

■ FreeCAD 샤프트 자동 설계 프로그램 개발 방법

제미나이는 유튜브 동영상 내용을 다른 AI 프로그램들 보다 빠르고, 정확하게 이해 하여 코드를 작성해 줍니다.
하지만 오류가 있습니다. 프로그램 설계 의도와 함께 제미나이가 만든 코드를 클로이드에 전달 하고 코드 작성을 요청 합니다. 작성된 코드를 FreeCAD로 테스트 한후, 프로그램 설계의도를 확인합니다. 제미나이 => 클로이드 => 제미나이 => 클로이드 순서로 몇번 왕복 할것입니다. 클로이드가 코드를 잘만들어 줍니다. 하지만 무료로 사용하기에는 허락된 사용량이 적습니다. 그래서 다른 AI 프로그램을 활용 해야 합니다.  AI에게 텍스트. 문서, 이미지, URL을 첨부하여 프로그램 설계의도를 전달 할 수 있습니다. 

Claude가 코드를 기가 막히게 짜주지만, 무료 버전은 사용량 제한이 아쉽죠. 그럴 땐 텍스트, 문서, 이미지, URL 첨부가 가능한 다른 멀티모달 AI들을 적절히 섞어 쓰면 비용을 아끼면서도 최고의 결과물을 낼 수 있습니다.

FreeCAD 샤프트 자동 설계 프로그램은, 기존 모델(템플릿 모델)을 사용하지 않고, 직접 프로그램이 모델을 만든것입니다.
유튜브 동영상을 참고하여 만들것 입니다.  자동 설계란 표준화 된 정보를 적용 가능 합니다. 즉 조건(IF)문을 넣을수 있습니다. 고참의 설계 노하우를 넣을수 있습니다. 만일 AI가 비슷한 형태의 수십만장늬 이미지를 이해 한다면, 자동으로  조건(IF)문을 만들지 않을까요? 그렇게 된다면 A사의 Shaft 설계는 고객의 요청에 의해 자동으로 만들어 지고, CNC 선반에 자동으로 지시를 하고 가공이 될것입니다. 3, 4년 후에는 기계가 사용 할 수 있는(코딩), G-CODE 생성이 가능한 CAD만 남을것입니다. 

유튜브 동영상을 참고하고 AI의 도움을 받아 아주 짧은 시간 안에 완성했는데요. 내가 직접 스케치한 내용을 동영상으로 캡처해서 AI에게 학습 자료로 주면, AI가 그걸 보고 그대로 코딩을 해주는 세상이 이미 온 것입니다.

아래 코드는 유튜브와 이미지 그리고 AI를 활용하여 짧은 시간안에 만들었습니다. 사용자가 직접 스케치한 내용을 동영상 캡쳐하여 AI 학습 자료를 만들수 있습니다.

 

▶ 샤프트 자동 설계 코드

"""
FreeCAD Part Design - 다단 샤프트(Multi-step Shaft) 자동 생성기
유튜브 "Shaft Design Wizard" 방식 적용 (계단형 단일 스케치 + 1회 회전)
"""
import FreeCAD
import FreeCADGui
import Part
import Sketcher
from PySide6 import QtWidgets, QtCore


class MultiStepShaftDialog(QtWidgets.QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("단일 스케치 기반 다단 샤프트 생성기")
        self.setFixedWidth(460)
        self.setWindowFlags(
            self.windowFlags() | QtCore.Qt.WindowType.WindowStaysOnTopHint
        )
        self.inputs = []
        self._apply_used = False   # ★ 단수 적용 버튼 사용 여부 추적
        self._build_ui()
        self._check_existing_shaft()

    # ──────────────────────────────────────────────
    # UI 구성
    # ──────────────────────────────────────────────
    def _build_ui(self):
        self.root = QtWidgets.QVBoxLayout(self)

        # ── 1. 단(Step) 개수 설정부 ──────────────
        row = QtWidgets.QHBoxLayout()
        row.addWidget(QtWidgets.QLabel("단(Step) 개수 설정:"))

        self.spin_count = QtWidgets.QSpinBox()
        self.spin_count.setRange(1, 10)
        self.spin_count.setValue(3)
        row.addWidget(self.spin_count)

        self.btn_apply = QtWidgets.QPushButton("단수 적용")
        self.btn_apply.setFixedWidth(75)
        self.btn_apply.clicked.connect(self._on_apply_count)
        row.addWidget(self.btn_apply)
        self.root.addLayout(row)

        self.lbl_count_hint = QtWidgets.QLabel("")
        self.lbl_count_hint.setStyleSheet("color: #888; font-size: 11px;")
        self.root.addWidget(self.lbl_count_hint)

        # ── 2. 헤더 ──────────────────────────────
        header = QtWidgets.QHBoxLayout()
        header.addWidget(QtWidgets.QLabel("  단"), 1)
        header.addWidget(QtWidgets.QLabel("직경 (mm)"), 2)
        header.addWidget(QtWidgets.QLabel("길이 (mm)"), 2)
        self.root.addLayout(header)

        # ── 3. 동적 입력 필드 ────────────────────
        self.dynamic_layout = QtWidgets.QVBoxLayout()
        self.root.addLayout(self.dynamic_layout)
        self._generate_fields(3)

        # ── 4. 생성 버튼 ─────────────────────────
        self.btn_create = QtWidgets.QPushButton("🚀  샤프트 생성(수정)")
        self.btn_create.setStyleSheet(
            "background-color: #1E88E5; color: white; "
            "font-weight: bold; padding: 10px; border-radius: 5px;"
        )
        self.btn_create.clicked.connect(self._on_create)
        self.root.addWidget(self.btn_create)

        # ── 5. 상태 레이블 ───────────────────────
        self.lbl_status = QtWidgets.QLabel("")
        self.lbl_status.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
        self.lbl_status.setStyleSheet("color: #c0392b; font-weight: bold; font-size: 12px;")
        self.root.addWidget(self.lbl_status)

    # ──────────────────────────────────────────────
    # 단수 적용 버튼 비활성화 헬퍼
    # ──────────────────────────────────────────────
    def _disable_apply_btn(self):
        """단수 적용 버튼과 SpinBox를 영구적으로 비활성화한다."""
        self._apply_used = True
        self.btn_apply.setEnabled(False)
        self.btn_apply.setStyleSheet(
            "background-color: #aaa; color: #fff; border-radius: 3px;"
        )
        self.spin_count.setEnabled(False)

    def _on_apply_count(self):
        requested = self.spin_count.value()
        current   = len(self.inputs)

        if requested < current:
            # 줄이기 시도 → 경고 후 값 복원, 버튼은 유지
            self.spin_count.setValue(current)
            QtWidgets.QMessageBox.warning(
                self,
                "단수 변경 불가",
                f"현재 {current}단이 설정되어 있습니다.\n단수는 줄일 수 없습니다. (늘리는 것만 가능)"
            )
            return

        # requested >= current: 늘리거나 같은 값 → 안내 메시지 없이 그냥 확정
        if requested > current:
            self._append_fields(current, requested)

        # ★ 클릭 완료 → 버튼·SpinBox 영구 비활성화
        self._disable_apply_btn()

    def _generate_fields(self, count):
        self._clear_layout(self.dynamic_layout)
        self.inputs = []
        for i in range(count):
            self._add_field_row(i, max(5.0, 50.0 - i * 10.0), 60.0)
        self._update_spin_minimum()

    def _append_fields(self, old_count, new_count):
        for i in range(old_count, new_count):
            self._add_field_row(i, max(5.0, 50.0 - i * 10.0), 60.0)
        self._update_spin_minimum()

    def _add_field_row(self, index, default_d, default_l):
        row = QtWidgets.QHBoxLayout()
        row.addWidget(QtWidgets.QLabel(f"  [{index + 1}단]"), 1)

        d_spin = QtWidgets.QDoubleSpinBox()
        d_spin.setRange(1.0, 5000.0)
        d_spin.setValue(default_d)
        d_spin.setSuffix(" mm")
        row.addWidget(d_spin, 2)

        l_spin = QtWidgets.QDoubleSpinBox()
        l_spin.setRange(1.0, 5000.0)
        l_spin.setValue(default_l)
        l_spin.setSuffix(" mm")
        row.addWidget(l_spin, 2)

        self.dynamic_layout.addLayout(row)
        self.inputs.append((d_spin, l_spin))

    def _update_spin_minimum(self):
        current = len(self.inputs)
        self.spin_count.setMinimum(current)
        self.spin_count.setValue(current)
        self.lbl_count_hint.setText(f"※ 현재 {current}단 설정됨 — 단수는 늘리기만 가능합니다.")

    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 _check_existing_shaft(self):
        """
        기존 샤프트(Shaft_Profile 스케치)가 있으면:
          1) 스케치 치수 구속에서 단수·직경·길이를 역산하여 UI 필드에 채운다.
          2) 단수 적용 버튼·SpinBox를 즉시 비활성화한다.
          3) 상태 레이블에 기존 샤프트 존재 안내를 표시한다.
        없으면 아무것도 하지 않는다.
        """
        doc = FreeCAD.ActiveDocument
        if not doc:
            return

        sketch = doc.getObject("Shaft_Profile")
        if sketch is None:
            return  # 기존 샤프트 없음 → 기본 상태 유지

        # ── 스케치 치수 구속에서 단수 정보 역산 ──────────────────
        # 생성 규칙:
        #   수평선 인덱스 h_idx = 2*i + 1  (i = 0,1,2...)
        #   DistanceX(h_idx,1,h_idx,2) → 해당 단의 길이
        #   DistanceY(-1,1,h_idx,1)    → 해당 단의 반경
        steps_data = {}   # { step_index: {"radius": r, "length": l} }

        try:
            for con in sketch.Constraints:
                # 길이 구속: DistanceX, 동일 선분 양 끝점
                if (con.Type == "DistanceX"
                        and con.First == con.Second          # 같은 선분
                        and con.FirstPos == 1                # 시작점
                        and con.SecondPos == 2):             # 끝점
                    h_idx = con.First
                    if h_idx % 2 == 1:                       # 홀수 = 수평선
                        i = (h_idx - 1) // 2
                        steps_data.setdefault(i, {})["length"] = abs(con.Value)

                # 반경 구속: DistanceY, 원점(-1) → 수평선 시작점
                if (con.Type == "DistanceY"
                        and con.First == -1                  # 스케치 원점
                        and con.FirstPos == 1
                        and con.SecondPos == 1):             # 대상 선분 시작점
                    h_idx = con.Second
                    if h_idx % 2 == 1:
                        i = (h_idx - 1) // 2
                        steps_data.setdefault(i, {})["radius"] = abs(con.Value)
        except Exception:
            # 구속 읽기 실패 시 존재 여부만 표시하고 종료
            self.lbl_status.setText("⚠  기존 샤프트가 존재합니다. 생성(수정) 시 덮어씁니다.")
            self.lbl_status.setStyleSheet("color: #e67e22; font-weight: bold; font-size: 12px;")
            self._disable_apply_btn()
            return

        # ── 읽어온 단수 정보로 UI 재구성 ────────────────────────
        n = len(steps_data)
        if n == 0:
            # 구속 정보를 전혀 못 읽은 경우 — 존재 안내만 표시
            self.lbl_status.setText("⚠  기존 샤프트가 존재합니다. 생성(수정) 시 덮어씁니다.")
            self.lbl_status.setStyleSheet("color: #e67e22; font-weight: bold; font-size: 12px;")
            self._disable_apply_btn()
            return

        # 필드를 다시 그린다 (기존 기본 3단 필드 교체)
        self._clear_layout(self.dynamic_layout)
        self.inputs = []
        for i in sorted(steps_data.keys()):
            d = steps_data[i].get("radius", 10.0) * 2.0   # 반경 → 직경
            l = steps_data[i].get("length", 60.0)
            self._add_field_row(i, d, l)

        # SpinBox 표시값을 읽어온 단수로 갱신
        self.spin_count.setMinimum(n)
        self.spin_count.setValue(n)
        self.lbl_count_hint.setText(
            f"※ 현재 {n}단 설정됨 (기존 모델에서 읽어옴) — 단수는 늘리기만 가능합니다."
        )

        # 단수 적용 버튼·SpinBox 잠금
        self._disable_apply_btn()

        # 상태 레이블
        self.lbl_status.setText(
            f"⚠  기존 {n}단 샤프트가 존재합니다. 직경/길이 수정 후 [샤프트 생성(수정)]을 클릭하세요."
        )
        self.lbl_status.setStyleSheet("color: #e67e22; font-weight: bold; font-size: 12px;")

    def _on_create(self):
        try:
            self._create_shaft()
        except Exception as e:
            QtWidgets.QMessageBox.critical(self, "오류", f"샤프트 생성 중 오류가 발생했습니다:\n\n{str(e)}")

    def _create_shaft(self):
        doc = FreeCAD.ActiveDocument
        if not doc:
            doc = FreeCAD.newDocument("MultiStepShaft")

        for name in ("Shaft_Revolution", "Shaft_Profile"):
            existing = doc.getObject(name)
            if existing:
                doc.removeObject(name)
        doc.recompute()

        body = doc.getObject("Body")
        if not body:
            body = doc.addObject("PartDesign::Body", "Body")
        doc.recompute()

        xy_plane = self._find_xy_plane(doc, body)
        if xy_plane is None:
            raise RuntimeError("XY 평면을 찾을 수 없습니다.")

        sketch = body.newObject("Sketcher::SketchObject", "Shaft_Profile")
        sketch.AttachmentSupport = [(xy_plane, "")]
        sketch.MapMode = "FlatFace"
        doc.recompute()

        steps = [{"radius": d.value() / 2.0, "length": l.value()} for d, l in self.inputs]

        # ── 5. 윤곽선 좌표 계산 (계단식 테두리만 생성) ──
        lines = []
        prev_r = 0.0
        prev_x = 0.0

        for i, step in enumerate(steps):
            r = step["radius"]
            l = step["length"]
            curr_x = prev_x + l

            if i == 0:
                # 1단 수직선 (원점 -> 반경)
                v_seg = Part.LineSegment(FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(0, r, 0))
                lines.append(sketch.addGeometry(v_seg, False))
            else:
                # 단차 수직선 (이전 반경 -> 현재 반경)
                v_seg = Part.LineSegment(FreeCAD.Vector(prev_x, prev_r, 0), FreeCAD.Vector(prev_x, r, 0))
                lines.append(sketch.addGeometry(v_seg, False))

            # 수평선 (현재 반경에서 X 이동)
            h_seg = Part.LineSegment(FreeCAD.Vector(prev_x, r, 0), FreeCAD.Vector(curr_x, r, 0))
            lines.append(sketch.addGeometry(h_seg, False))

            prev_x = curr_x
            prev_r = r

        # 마지막 닫힘 수직선 (마지막 반경 -> 축으로 내려오기)
        v_end = Part.LineSegment(FreeCAD.Vector(prev_x, prev_r, 0), FreeCAD.Vector(prev_x, 0, 0))
        lines.append(sketch.addGeometry(v_end, False))

        # 마지막 닫힘 수평선 (원점으로 돌아가기)
        h_close = Part.LineSegment(FreeCAD.Vector(prev_x, 0, 0), FreeCAD.Vector(0, 0, 0))
        lines.append(sketch.addGeometry(h_close, False))

        # ── 7. 구속 조건 할당 (완전 구속 닫힌 루프) ──
        # [7-1] 닫힌 루프 생성 (모든 선분을 꼬리잡기 형태로 Coincident 연결)
        for i in range(len(lines) - 1):
            sketch.addConstraint(Sketcher.Constraint("Coincident", i, 2, i + 1, 1))
        # 마지막 선의 끝점을 첫 선의 시작점(원점 위치)에 연결
        sketch.addConstraint(Sketcher.Constraint("Coincident", len(lines) - 1, 2, 0, 1))

        # [7-2] 스케치 원점에 고정
        sketch.addConstraint(Sketcher.Constraint("Coincident", 0, 1, -1, 1))

        # [7-3] 수직 / 수평 구속 (짝수 인덱스는 수직, 홀수는 수평)
        for i in range(len(lines)):
            if i % 2 == 0:
                sketch.addConstraint(Sketcher.Constraint("Vertical", i))
            else:
                sketch.addConstraint(Sketcher.Constraint("Horizontal", i))

        # [7-4] 치수 구속
        for i, step in enumerate(steps):
            r, l = step["radius"], step["length"]
            h_idx = 2 * i + 1  # 수평선의 인덱스

            # 각 단의 길이 고정 (DistanceX)
            sketch.addConstraint(Sketcher.Constraint("DistanceX", h_idx, 1, h_idx, 2, l))

            # 각 단의 반경 고정 (DistanceY: 스케치 원점 ↔ 수평선 시작점)
            sketch.addConstraint(Sketcher.Constraint("DistanceY", -1, 1, h_idx, 1, r))

        doc.recompute()

        # ── 8. Revolution ─────────────────────────
        rev = body.newObject("PartDesign::Revolution", "Shaft_Revolution")
        rev.Profile = sketch
        rev.ReferenceAxis = (sketch, ["H_Axis"])
        rev.Angle = 360.0
        doc.recompute()

        # ── 9. 뷰 자동 정렬 ──────────────────────
        try:
            FreeCADGui.activeDocument().activeView().viewIsometric()
            FreeCADGui.SendMsgToActiveView("ViewFit")
        except Exception:
            pass

        self.lbl_status.setText("✅  샤프트 생성(수정) 완료")
        self.lbl_status.setStyleSheet("color: #27ae60; font-weight: bold; font-size: 12px;")

        QtWidgets.QMessageBox.information(
            self,
            "완료",
            f"{len(steps)}단 샤프트 자동 생성이 완료되었습니다!\n\n"
            "(완전 구속 단일 스케치 적용됨)\n\n"
            "※ 직경/길이 수정 후 다시 [샤프트 생성(수정)]을 클릭하면 재생성됩니다.",
        )

    @staticmethod
    def _find_xy_plane(doc, body):
        for obj in getattr(body, "Group", []):
            if "XY" in obj.Label and hasattr(obj, "Shape"):
                return obj

        origin = doc.getObject("Origin")
        if origin:
            for attr in ("OriginFeatures", "Group"):
                for child in getattr(origin, attr, []):
                    if "XY" in child.Label:
                        return child

        for name in ("XY_Plane", "XY_Plane001"):
            obj = doc.getObject(name)
            if obj is not None:
                return obj

        return None


def main():
    for w in QtWidgets.QApplication.topLevelWidgets():
        if isinstance(w, MultiStepShaftDialog):
            w.close()

    dlg = MultiStepShaftDialog(FreeCADGui.getMainWindow())
    dlg.show()

if __name__ == "__main__":
    main()

 

▶ Shaft 자동 설계 프로그램 실행 동영상

 

반응형