반응형
■ 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
반응형
'업무 자동화 > FreeCAD' 카테고리의 다른 글
| AI를 학습 시킬수 있을까? - 컨셉 (0) | 2026.06.05 |
|---|---|
| AI로 자동 설계 프로그램 만들기 - N단 Shaft (업데이트) (0) | 2026.06.01 |
| AI로 자동 설계 프로그램 만들기 - 2단 Shaft (0) | 2026.05.30 |
| AI로 자동 설계 프로그램 만들기 - 간단한 단일 Shaft (0) | 2026.05.29 |
| Python] 사각기둥 만들기 코드 분석 (0) | 2026.05.28 |