Last active
June 20, 2026 03:43
-
-
Save palaniraja/fc53d1b2caa8646f86015aebc27f29e9 to your computer and use it in GitHub Desktop.
Freecad macro to generate Side flexing hook and mate from two selected parallel edges
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import FreeCAD as App | |
| import FreeCADGui as Gui | |
| import Part | |
| from FreeCAD import Vector | |
| # Cross-version PySide wrapper to handle old and new FreeCAD environments seamlessly | |
| try: | |
| from PySide6 import QtWidgets, QtCore | |
| except ImportError: | |
| try: | |
| from PySide2 import QtWidgets, QtCore | |
| except ImportError: | |
| from PySide import QtWidgets, QtCore | |
| # ========================================================================= | |
| # GEOMETRY ENGINE PROXIES (Data Tab Properties & Math) | |
| # ========================================================================= | |
| class SnapFitPositiveProxy: | |
| def __init__(self, obj): | |
| obj.Proxy = self | |
| def execute(self, obj): | |
| w = obj.ClipWidth.Value | |
| l = obj.HookLength.Value | |
| ov = obj.Overlap.Value | |
| b = obj.Bite.Value | |
| sw = obj.SlotWidth.Value | |
| t = obj.WallThickness.Value | |
| off = obj.PositionOffset.Value | |
| inv = obj.InvertVertical | |
| ct = t * 0.5 # Hook thickness is 50% of total wall thickness | |
| if w <= 0 or l <= 0: | |
| return | |
| ym = -1.0 if inv else 1.0 | |
| # Build Prong Profile | |
| pts = [ | |
| Vector(-w/2 + off, ov * ym, 0), | |
| Vector(w/2 + off, ov * ym, 0), | |
| Vector(w/2 + off, (-l + 2) * ym, 0), | |
| Vector(w/2 + b + off, (-l + 1) * ym, 0), | |
| Vector(w/2 - 0.4 + off, -l * ym, 0), | |
| Vector(-w/2 + 0.4 + off, -l * ym, 0), | |
| Vector(-w/2 - b + off, (-l + 1) * ym, 0), | |
| Vector(-w/2 + off, (-l + 2) * ym, 0), | |
| Vector(-w/2 + off, ov * ym, 0) | |
| ] | |
| f_h = Part.Face(Part.makePolygon(pts)) | |
| pts_s = [ | |
| Vector(-sw/2 + off, 0.1 * ym, 0), | |
| Vector(sw/2 + off, 0.1 * ym, 0), | |
| Vector(sw/2 + off, (-l - 0.1) * ym, 0), | |
| Vector(-sw/2 + off, (-l - 0.1) * ym, 0), | |
| Vector(-sw/2 + off, 0.1 * ym, 0) | |
| ] | |
| f_s = Part.Face(Part.makePolygon(pts_s)) | |
| prof = f_h.cut(f_s) | |
| # Foolproof centering: Shift the face geometry back by half its thickness before extruding | |
| shift_mat = App.Matrix() | |
| shift_mat.move(Vector(0, 0, -ct / 2)) | |
| prof = prof.transformGeometry(shift_mat) | |
| # Extrude from -ct/2 to +ct/2, centering it perfectly on the origin | |
| sol = prof.extrude(Vector(0, 0, ct)) | |
| obj.Shape = sol | |
| class SnapFitNegativeProxy: | |
| def __init__(self, obj): | |
| obj.Proxy = self | |
| def execute(self, obj): | |
| w = obj.ClipWidth.Value | |
| l = obj.HookLength.Value | |
| ov = obj.Overlap.Value | |
| b = obj.Bite.Value | |
| cl = obj.Clearance.Value | |
| t = obj.WallThickness.Value | |
| off = obj.PositionOffset.Value | |
| inv = obj.InvertVertical | |
| ct = t * 0.5 | |
| total_thick = ct + cl * 2 | |
| if w <= 0 or l <= 0: | |
| return | |
| ym = -1.0 if inv else 1.0 | |
| # Build Clearance Cutout Tool Profile | |
| pts = [ | |
| Vector(-w/2 - cl + off, ov * ym, 0), | |
| Vector(w/2 + cl + off, ov * ym, 0), | |
| Vector(w/2 + cl + off, (-l + 2.1) * ym, 0), | |
| Vector(w/2 + b + cl + off, (-l + 1.1) * ym, 0), | |
| Vector(w/2 + b + cl + off, (-l - cl) * ym, 0), | |
| Vector(-w/2 - b - cl + off, (-l - cl) * ym, 0), | |
| Vector(-w/2 - b - cl + off, (-l + 1.1) * ym, 0), | |
| Vector(-w/2 - cl + off, (-l + 2.1) * ym, 0), | |
| Vector(-w/2 - cl + off, ov * ym, 0) | |
| ] | |
| f_c = Part.Face(Part.makePolygon(pts)) | |
| # Foolproof centering for cutout: Shift back by half of the total cleared thickness | |
| shift_mat = App.Matrix() | |
| shift_mat.move(Vector(0, 0, -total_thick / 2)) | |
| f_c = f_c.transformGeometry(shift_mat) | |
| sol = f_c.extrude(Vector(0, 0, total_thick)) | |
| obj.Shape = sol | |
| # ========================================================================= | |
| # VIEWPORT RENDER PROXIES | |
| # ========================================================================= | |
| class SnapFitViewProxy: | |
| def __init__(self, vobj): | |
| vobj.Proxy = self | |
| def getDisplayModes(self, obj): | |
| return ["Flat Lines", "Shaded", "Wireframe"] | |
| def getDefaultDisplayMode(self): | |
| return "Flat Lines" | |
| def updateData(self, obj, prop): | |
| return | |
| # ========================================================================= | |
| # CONFIGURATION INTERFACE (UI) | |
| # ========================================================================= | |
| class SnapFitGeneratorDialog(QtWidgets.QDialog): | |
| def __init__(self, e1, e2): | |
| super(SnapFitGeneratorDialog, self).__init__() | |
| self.e1 = e1 | |
| self.e2 = e2 | |
| self.init_ui() | |
| def init_ui(self): | |
| self.setWindowTitle("Parametric Snap Creator") | |
| form = QtWidgets.QFormLayout() | |
| self.sb_w = QtWidgets.QDoubleSpinBox() | |
| self.sb_w.setValue(8.0) | |
| form.addRow("Width:", self.sb_w) | |
| self.sb_l = QtWidgets.QDoubleSpinBox() | |
| self.sb_l.setValue(5.0) | |
| form.addRow("Length (Depth):", self.sb_l) | |
| self.sb_ov = QtWidgets.QDoubleSpinBox() | |
| self.sb_ov.setValue(2.0) | |
| form.addRow("Mating Overlap:", self.sb_ov) | |
| self.sb_b = QtWidgets.QDoubleSpinBox() | |
| self.sb_b.setValue(0.5) | |
| form.addRow("Locking Bite:", self.sb_b) | |
| self.sb_sw = QtWidgets.QDoubleSpinBox() | |
| self.sb_sw.setValue(1.5) | |
| form.addRow("Flex Slot Width:", self.sb_sw) | |
| self.sb_cl = QtWidgets.QDoubleSpinBox() | |
| self.sb_cl.setValue(0.15) | |
| form.addRow("Clearance Tolerance:", self.sb_cl) | |
| self.sb_off = QtWidgets.QDoubleSpinBox() | |
| self.sb_off.setRange(-999999.0, 999999.0) # Unbounded movement limit | |
| self.sb_off.setValue(0.0) | |
| form.addRow("Position Offset:", self.sb_off) | |
| self.cb_inv = QtWidgets.QCheckBox() | |
| self.cb_inv.setChecked(False) | |
| form.addRow("Invert Vertical Direction", self.cb_inv) | |
| QBB = QtWidgets.QDialogButtonBox | |
| btn = QBB(QBB.Ok | QBB.Cancel) | |
| btn.accepted.connect(self.generate) | |
| btn.rejected.connect(self.reject) | |
| layout = QtWidgets.QVBoxLayout() | |
| layout.addLayout(form) | |
| layout.addWidget(btn) | |
| self.setLayout(layout) | |
| def generate(self): | |
| v1_s = self.e1.Vertexes[0].Point | |
| v1_e = self.e1.Vertexes[-1].Point | |
| v2_s = self.e2.Vertexes[0].Point | |
| v2_e = self.e2.Vertexes[-1].Point | |
| m1 = (v1_s + v1_e) * 0.5 | |
| m2 = (v2_s + v2_e) * 0.5 | |
| u_len = (v1_e - v1_s).normalize() | |
| v_across = m2 - m1 | |
| u_height = u_len.cross(v_across).normalize() | |
| u_thick = u_height.cross(u_len).normalize() | |
| pos1_s = 0.0 | |
| pos1_e = (v1_e - v1_s).dot(u_len) | |
| pos2_s = (v2_s - v1_s).dot(u_len) | |
| pos2_e = (v2_e - v1_s).dot(u_len) | |
| min1, max1 = min(pos1_s, pos1_e), max(pos1_s, pos1_e) | |
| min2, max2 = min(pos2_s, pos2_e), max(pos2_s, pos2_e) | |
| overlap_start = max(min1, min2) | |
| overlap_end = min(max1, max2) | |
| if overlap_end > overlap_start: | |
| center_x = (overlap_start + overlap_end) * 0.5 | |
| else: | |
| center_x = (min1 + max1 + min2 + max2) * 0.25 | |
| wall_t = abs(v_across.dot(u_thick)) | |
| center_z = v_across.dot(u_thick) * 0.5 | |
| center = v1_s + (u_len * center_x) + (u_thick * center_z) | |
| mat = App.Matrix() | |
| mat.A11 = u_len.x; mat.A12 = u_height.x; mat.A13 = u_thick.x; mat.A14 = center.x | |
| mat.A21 = u_len.y; mat.A22 = u_height.y; mat.A23 = u_thick.y; mat.A24 = center.y | |
| mat.A31 = u_len.z; mat.A32 = u_height.z; mat.A33 = u_thick.z; mat.A34 = center.z | |
| tp = App.Placement(mat) | |
| doc = App.activeDocument() | |
| o_h = doc.addObject("Part::FeaturePython", "Parametric_Snap_Hook") | |
| o_c = doc.addObject("Part::FeaturePython", "Parametric_Snap_Cutout") | |
| import SideFlexSnapFit | |
| o_h.Proxy = SideFlexSnapFit.SnapFitPositiveProxy(o_h) | |
| o_c.Proxy = SideFlexSnapFit.SnapFitNegativeProxy(o_c) | |
| if App.GuiUp: | |
| o_h.ViewObject.Proxy = SideFlexSnapFit.SnapFitViewProxy(o_h.ViewObject) | |
| o_c.ViewObject.Proxy = SideFlexSnapFit.SnapFitViewProxy(o_c.ViewObject) | |
| for o in [o_h, o_c]: | |
| o.addProperty("App::PropertyLength", "ClipWidth", "SnapFit") | |
| o.ClipWidth = self.sb_w.value() | |
| o.addProperty("App::PropertyLength", "HookLength", "SnapFit") | |
| o.HookLength = self.sb_l.value() | |
| o.addProperty("App::PropertyLength", "Overlap", "SnapFit") | |
| o.Overlap = self.sb_ov.value() | |
| o.addProperty("App::PropertyLength", "Bite", "SnapFit") | |
| o.Bite = self.sb_b.value() | |
| o.addProperty("App::PropertyLength", "SlotWidth", "SnapFit") | |
| o.SlotWidth = self.sb_sw.value() | |
| o.addProperty("App::PropertyLength", "Clearance", "SnapFit") | |
| o.Clearance = self.sb_cl.value() | |
| o.addProperty("App::PropertyLength", "WallThickness", "SnapFit") | |
| o.WallThickness = wall_t | |
| o.addProperty("App::PropertyDistance", "PositionOffset", "SnapFit") | |
| o.PositionOffset = self.sb_off.value() | |
| o.addProperty("App::PropertyBool", "InvertVertical", "SnapFit") | |
| o.InvertVertical = self.cb_inv.isChecked() | |
| o.Placement = tp | |
| o.touch() | |
| if App.GuiUp: | |
| o_h.ViewObject.ShapeColor = (1.0, 0.8, 0.2) # yellow | |
| o_c.ViewObject.ShapeColor = (1.0, 0.3, 0.2) # red | |
| o_h.ViewObject.Transparency = 50 | |
| o_c.ViewObject.Transparency = 70 | |
| o_h.ViewObject.Visibility = True | |
| o_c.ViewObject.Visibility = True | |
| doc.recompute() | |
| self.accept() | |
| def run_macro(): | |
| edges = [] | |
| for sel in Gui.Selection.getSelectionEx(): | |
| for sub in sel.SubObjects: | |
| if sub.ShapeType == "Edge": | |
| edges.append(sub) | |
| if len(edges) != 2: | |
| QtWidgets.QMessageBox.critical(None, "Error", "Select exactly TWO parallel wall edges.") | |
| return | |
| dialog = SnapFitGeneratorDialog(edges[0], edges[1]) | |
| dialog.exec() | |
| # FIXED: Execution Guard protects module imports from re-running the macro window loop | |
| if __name__ == "__main__": | |
| run_macro() |
palaniraja
commented
Jun 17, 2026
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment