"""FreeCAD Macro: Cut Object for Magnets. SPDX-License-Identifier: MIT Copyright (c) 2025 Sean P. Kane (GitHub: spkane) Cuts an object along a plane and adds connector holes for magnets with surface collision detection. Requirements: - FreeCAD 0.19 or later - An object selected in the 3D view Usage: 1. Select the object to cut 2. Run the macro 3. Configure cut plane and hole parameters 4. Click "Execute Cut" """ # FreeCAD Addon Manager metadata __Name__ = "Cut Object for Magnets" __Comment__ = "Cut an object along a plane and add aligned magnet holes with surface collision detection" __Author__ = "Sean P. Kane" __Version__ = "0.6.0" __Date__ = "2026-01-12" __License__ = "MIT" __Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more" __Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme" __Icon__ = "" __Help__ = "Select an object to cut, run the macro, configure cut plane and magnet hole parameters, then click Execute Cut. Creates two parts with aligned magnet holes." __Status__ = "Beta" __Requires__ = "FreeCAD 0.19+" __Communication__ = "https://github.com/spkane/freecad-robust-mcp-and-more/issues" __Files__ = "" import FreeCAD as App import FreeCADGui as Gui import Part from PySide import QtGui class HolePlacementError(Exception): """Raised when hole placement fails.""" pass class CutObjectForMagnetsDialog(QtGui.QDialog): """Dialog for configuring cut parameters and magnet holes.""" def __init__(self, parent=None): super(CutObjectForMagnetsDialog, self).__init__(parent) self.setWindowTitle("Cut Object for Magnets") self.setModal(True) self.setup_ui() def setup_ui(self): """Initialize the user interface.""" layout = QtGui.QVBoxLayout() # Object selection - allow user to choose which body to cut obj_group = QtGui.QGroupBox("Object to Cut") obj_layout = QtGui.QFormLayout() self.obj_combo = QtGui.QComboBox() self.obj_combo.setToolTip("Select the object to cut") self._populate_cuttable_objects() obj_layout.addRow("Body:", self.obj_combo) obj_group.setLayout(obj_layout) layout.addWidget(obj_group) # Cut plane configuration plane_group = QtGui.QGroupBox("Cut Plane") plane_layout = QtGui.QFormLayout() # Plane type selector self.plane_type_combo = QtGui.QComboBox() self.plane_type_combo.addItems(["Preset Plane", "Model Plane"]) self.plane_type_combo.currentIndexChanged.connect(self._on_plane_type_changed) plane_layout.addRow("Plane Type:", self.plane_type_combo) # Preset plane combo (XY, XZ, YZ) self.plane_combo = QtGui.QComboBox() self.plane_combo.addItems(["XY", "XZ", "YZ"]) plane_layout.addRow("Preset:", self.plane_combo) # Model plane combo (populated with available planes) self.model_plane_combo = QtGui.QComboBox() self.model_plane_combo.setVisible(False) plane_layout.addRow("Model Plane:", self.model_plane_combo) # Offset (only for preset planes) self.offset_spin = QtGui.QDoubleSpinBox() self.offset_spin.setRange(-10000, 10000) self.offset_spin.setValue(0.0) self.offset_spin.setSuffix(" mm") self.offset_spin.setToolTip("Offset from origin along plane normal") plane_layout.addRow("Offset:", self.offset_spin) plane_group.setLayout(plane_layout) layout.addWidget(plane_group) # Populate model planes self._populate_model_planes() # Hole configuration hole_group = QtGui.QGroupBox("Magnet Holes") hole_layout = QtGui.QFormLayout() self.diameter_spin = QtGui.QDoubleSpinBox() self.diameter_spin.setRange(0.1, 100) self.diameter_spin.setValue(3.0) self.diameter_spin.setSuffix(" mm") self.diameter_spin.setDecimals(2) self.diameter_spin.setToolTip( "Diameter of magnet holes (e.g., magnet diameter)" ) hole_layout.addRow("Diameter:", self.diameter_spin) self.depth_spin = QtGui.QDoubleSpinBox() self.depth_spin.setRange(0.1, 100) self.depth_spin.setValue(3.0) self.depth_spin.setSuffix(" mm") self.depth_spin.setDecimals(2) self.depth_spin.setToolTip("Depth of holes from cut surface") hole_layout.addRow("Depth:", self.depth_spin) self.hole_count_spin = QtGui.QSpinBox() self.hole_count_spin.setRange(1, 100) self.hole_count_spin.setValue(6) self.hole_count_spin.setToolTip( "Total number of magnet holes to create, evenly spaced along the cut edge" ) hole_layout.addRow("Number of Holes:", self.hole_count_spin) self.clearance_preferred_spin = QtGui.QDoubleSpinBox() self.clearance_preferred_spin.setRange(0.1, 20) self.clearance_preferred_spin.setValue(2.0) self.clearance_preferred_spin.setSuffix(" mm") self.clearance_preferred_spin.setDecimals(1) self.clearance_preferred_spin.setToolTip( "Preferred distance from hole edge to object surface (used for initial placement)" ) hole_layout.addRow("Edge Clearance (Preferred):", self.clearance_preferred_spin) self.clearance_min_spin = QtGui.QDoubleSpinBox() self.clearance_min_spin.setRange(0.1, 20) self.clearance_min_spin.setValue(0.5) self.clearance_min_spin.setSuffix(" mm") self.clearance_min_spin.setDecimals(1) self.clearance_min_spin.setToolTip( "Minimum acceptable distance from hole edge to object surface (used during repositioning)" ) hole_layout.addRow("Edge Clearance (Minimum):", self.clearance_min_spin) hole_group.setLayout(hole_layout) layout.addWidget(hole_group) # Progress and status self.progress_bar = QtGui.QProgressBar() self.progress_bar.setVisible(False) layout.addWidget(self.progress_bar) self.status_label = QtGui.QLabel("") self.status_label.setWordWrap(True) layout.addWidget(self.status_label) # Buttons button_box = QtGui.QDialogButtonBox() self.execute_btn = button_box.addButton( "Execute Cut", QtGui.QDialogButtonBox.AcceptRole ) cancel_btn = button_box.addButton(QtGui.QDialogButtonBox.Cancel) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) layout.addWidget(button_box) self.setLayout(layout) def _populate_cuttable_objects(self): """Populate the object combo box with objects that can be cut.""" if not App.ActiveDocument: return self.obj_combo.clear() self.cuttable_objects = {} # Map combo box text to actual objects # First, collect all BaseFeature objects that belong to Bodies # These should not be offered as cuttable objects base_features = set() for obj in App.ActiveDocument.Objects: if hasattr(obj, "TypeId") and obj.TypeId == "PartDesign::Body": if hasattr(obj, "BaseFeature") and obj.BaseFeature: base_features.add(obj.BaseFeature.Name) for obj in App.ActiveDocument.Objects: # Only include objects with shapes that aren't planes if hasattr(obj, "Shape") and hasattr(obj.Shape, "Volume"): # Skip planes and other non-solid objects if hasattr(obj, "TypeId") and "Plane" in obj.TypeId: continue # Skip objects with zero or near-zero volume if obj.Shape.Volume < 0.001: continue # Skip hidden objects (intermediate Part::Feature objects) if hasattr(obj, "ViewObject") and obj.ViewObject: if not obj.ViewObject.Visibility: continue # Skip objects that are BaseFeatures of Bodies if obj.Name in base_features: continue # Skip objects with _Base suffix (macro-created intermediates) if obj.Name.endswith("_Base") or obj.Label.endswith("_Base"): continue # Get object type for display obj_type = _get_object_type(obj) if obj_type: label = f"{obj.Label} ({obj_type})" else: label = obj.Label self.obj_combo.addItem(label) self.cuttable_objects[label] = obj if self.obj_combo.count() == 0: self.obj_combo.addItem("No cuttable objects available") def set_selected_object(self, obj_name: str): """Set the default selected object in the combo box.""" for i in range(self.obj_combo.count()): if obj_name in self.obj_combo.itemText(i): self.obj_combo.setCurrentIndex(i) break def get_selected_object(self): """Get the currently selected object to cut.""" current_text = self.obj_combo.currentText() if current_text == "No cuttable objects available": return None return self.cuttable_objects.get(current_text) def set_default_plane(self, plane_label: str): """Set a specific plane as the default selection. Args: plane_label: The label text to match in the model plane combo """ # Switch to Model Plane mode self.plane_type_combo.setCurrentIndex(1) # "Model Plane" self._on_plane_type_changed(1) # Find and select the matching plane for i in range(self.model_plane_combo.count()): if plane_label in self.model_plane_combo.itemText(i): self.model_plane_combo.setCurrentIndex(i) break def _populate_model_planes(self): """Populate the model plane combo box with available planes and faces.""" if not App.ActiveDocument: return self.model_plane_combo.clear() self.plane_objects = {} # Map combo box text to actual objects # Find all datum planes in the document for obj in App.ActiveDocument.Objects: # Check for PartDesign datum planes if hasattr(obj, "TypeId"): if "PartDesign::Plane" in obj.TypeId or "Part::Plane" in obj.TypeId: label = f"Plane: {obj.Label}" self.model_plane_combo.addItem(label) self.plane_objects[label] = ("plane", obj) # Also allow using faces of objects as planes if hasattr(obj, "Shape") and hasattr(obj.Shape, "Faces"): if len(obj.Shape.Faces) > 0: for idx, face in enumerate(obj.Shape.Faces): # Only add planar faces if isinstance(face.Surface, Part.Plane): label = f"Face: {obj.Label} (Face{idx + 1})" self.model_plane_combo.addItem(label) self.plane_objects[label] = ("face", obj, idx) if self.model_plane_combo.count() == 0: self.model_plane_combo.addItem("No planes available") def _on_plane_type_changed(self, index): """Handle plane type selection change.""" is_model_plane = index == 1 # Show/hide appropriate controls self.plane_combo.setVisible(not is_model_plane) self.model_plane_combo.setVisible(is_model_plane) self.offset_spin.setEnabled(not is_model_plane) def get_selected_model_plane(self) -> tuple | None: """Get the selected model plane object. Returns: Tuple of (type, object, [face_index]) or None """ if self.plane_type_combo.currentText() != "Model Plane": return None current_text = self.model_plane_combo.currentText() if current_text == "No planes available": return None return self.plane_objects.get(current_text) def get_parameters(self) -> dict: """Get all parameters from the dialog.""" params = { "plane_type": self.plane_type_combo.currentText(), "plane": self.plane_combo.currentText(), "offset": self.offset_spin.value(), "diameter": self.diameter_spin.value(), "depth": self.depth_spin.value(), "hole_count": self.hole_count_spin.value(), "clearance_preferred": self.clearance_preferred_spin.value(), "clearance_min": self.clearance_min_spin.value(), "model_plane": self.get_selected_model_plane(), } return params def set_status(self, message: str, is_error: bool = False): """Update status message.""" if is_error: self.status_label.setStyleSheet("color: red;") else: self.status_label.setStyleSheet("color: green;") self.status_label.setText(message) def set_progress(self, value: int, maximum: int = 100): """Update progress bar.""" if not self.progress_bar.isVisible(): self.progress_bar.setVisible(True) self.progress_bar.setMaximum(maximum) self.progress_bar.setValue(value) QtGui.QApplication.processEvents() class SmartCutter: """Handles cutting objects and placing magnet holes with collision detection.""" def __init__(self, obj: Part.Feature, params: dict): """Initialize the cutter. Args: obj: FreeCAD object to cut params: Dictionary of parameters from dialog """ self.obj = obj self.params = params self.shape = obj.Shape # Detect existing holes from previous cuts self.existing_holes = self._detect_existing_holes() def _detect_existing_holes(self) -> list[dict]: """Detect existing magnet holes in the source object. Finds cylindrical faces that appear to be magnet holes based on their radius matching common magnet sizes (or the current diameter). Returns: List of dicts with hole info: center, axis, radius, depth """ holes = [] target_radius = self.params.get("diameter", 3.0) / 2 # Group cylindrical faces by their axis and approximate center # (a single hole creates one cylindrical face) for face in self.shape.Faces: if face.Surface.__class__.__name__ != "Cylinder": continue radius = face.Surface.Radius # Only consider holes with radius close to target (within 50% tolerance) # or small holes that are likely magnets (radius < 10mm) if radius > 10 and abs(radius - target_radius) > target_radius * 0.5: continue # Get the cylinder axis and a point on the axis axis = face.Surface.Axis center = face.Surface.Center # Get the face's bounding box to estimate hole depth bbox = face.BoundBox # The "depth" along the axis depth = max(bbox.XLength, bbox.YLength, bbox.ZLength) holes.append( { "center": App.Vector(center), "axis": App.Vector(axis), "radius": radius, "depth": depth, "face_center": face.CenterOfMass, } ) App.Console.PrintMessage( f"Detected {len(holes)} existing holes in source object\n" ) return holes def _project_existing_holes_to_cut_plane( self, cut_normal: App.Vector, cut_point: App.Vector ) -> list[App.Vector]: """Project existing hole positions onto the new cut plane. For each existing hole, finds where its axis intersects the cut plane. Only includes holes whose axis is roughly perpendicular to the cut plane (i.e., holes that would connect through the cut). Args: cut_normal: Normal vector of the cut plane cut_point: A point on the cut plane Returns: List of positions on the cut plane where existing holes should appear """ projected_positions = [] for hole in self.existing_holes: hole_axis = hole["axis"] hole_center = hole["center"] # Check if hole axis is roughly parallel to cut normal # (meaning the hole goes "through" perpendicular to the cut) dot = abs(hole_axis.dot(cut_normal)) if dot < 0.7: # Not aligned enough continue # Project the hole center onto the cut plane by finding where the # hole axis line intersects the plane. Uses parametric line-plane # intersection formula. denominator = hole_axis.dot(cut_normal) if abs(denominator) < 0.001: continue # Parallel to plane, no intersection t = (cut_point - hole_center).dot(cut_normal) / denominator intersection = hole_center + hole_axis * t projected_positions.append(intersection) App.Console.PrintMessage( f"Projected {len(projected_positions)} existing holes to cut plane\n" ) return projected_positions def get_cut_plane_normal_and_point(self) -> tuple[App.Vector, App.Vector]: """Get plane normal vector and point based on selected plane. Returns: Tuple of (normal_vector, point_on_plane) """ plane_type = self.params.get("plane_type", "Preset Plane") # Handle model planes if plane_type == "Model Plane": model_plane = self.params.get("model_plane") if not model_plane: raise HolePlacementError("No model plane selected") return self._extract_plane_from_model(model_plane) # Handle preset planes plane = self.params["plane"] offset = self.params["offset"] if plane == "XY": normal = App.Vector(0, 0, 1) point = App.Vector(0, 0, offset) elif plane == "XZ": normal = App.Vector(0, 1, 0) point = App.Vector(0, offset, 0) elif plane == "YZ": normal = App.Vector(1, 0, 0) point = App.Vector(offset, 0, 0) else: # Default to XY normal = App.Vector(0, 0, 1) point = App.Vector(0, 0, offset) return normal, point def _extract_plane_from_model( self, model_plane: tuple ) -> tuple[App.Vector, App.Vector]: """Extract normal and point from a FreeCAD plane object or face. Args: model_plane: Tuple of (type, object, [face_index]) Returns: Tuple of (normal_vector, point_on_plane) """ plane_type = model_plane[0] if plane_type == "plane": # Datum plane object plane_obj = model_plane[1] # Get the placement of the plane placement = plane_obj.Placement normal = placement.Rotation.multVec(App.Vector(0, 0, 1)) point = placement.Base return normal, point elif plane_type == "face": # Face of an object obj = model_plane[1] face_idx = model_plane[2] face = obj.Shape.Faces[face_idx] # Get normal at the center of the face u_mid = (face.ParameterRange[0] + face.ParameterRange[1]) / 2 v_mid = (face.ParameterRange[2] + face.ParameterRange[3]) / 2 normal = face.normalAt(u_mid, v_mid) point = face.CenterOfMass return normal, point else: raise HolePlacementError(f"Unknown plane type: {plane_type}") def cut_object(self) -> tuple[Part.Shape, Part.Shape]: """Cut the object along the specified plane. Works with arbitrary plane orientations by creating a large half-space (box) that is properly rotated to align with the cutting plane. Returns: Tuple of (bottom_part, top_part) where: - bottom_part is the portion in the negative normal direction - top_part is the portion in the positive normal direction """ normal, point = self.get_cut_plane_normal_and_point() # Create a large cutting box bbox = self.shape.BoundBox size = max(bbox.XLength, bbox.YLength, bbox.ZLength) * 3 # Create a box centered in XY at origin, extending from Z=0 to Z=size # This box will represent the half-space "above" the cutting plane half = size / 2 box = Part.makeBox(size, size, size, App.Vector(-half, -half, 0)) # Rotate the box so its bottom face (originally Z=0) aligns with the plane # We need a rotation that transforms the Z-axis to the plane normal z_axis = App.Vector(0, 0, 1) rotation = App.Rotation(z_axis, normal) # Apply the rotation using a transformation matrix box = box.transformed(App.Matrix(rotation.toMatrix())) # Translate the box so the rotated Z=0 plane passes through the cut point box.translate(point) # Perform cuts # "bottom" = original minus the half-space above the plane # "top" = original intersected with the half-space above the plane try: bottom_part = self.shape.cut(box) top_part = self.shape.common(box) return bottom_part, top_part except Exception as e: raise HolePlacementError(f"Failed to cut object: {e!s}") from e def get_cut_face_center( self, part: Part.Shape, normal: App.Vector ) -> App.Vector | None: """Find the center of the cut face on a part. Args: part: The part shape normal: Normal vector of the cut plane Returns: Center point of cut face or None if not found """ # Get the cut plane point to filter candidates _, cut_point = self.get_cut_plane_normal_and_point() best_face = None best_dist = float("inf") for face in part.Faces: # Check if face is roughly parallel to cut plane face_normal = face.normalAt(0, 0) dot = abs(face_normal.dot(normal)) if dot > 0.99: # Nearly parallel # Check how close this face is to the cut plane face_center = face.CenterOfMass # Project face center onto plane normal and measure distance to cut point dist_along_normal = abs((face_center - cut_point).dot(normal)) if dist_along_normal < best_dist: best_dist = dist_along_normal best_face = face if best_face is not None: return best_face.CenterOfMass return None def is_hole_safe( self, center: App.Vector, direction: App.Vector, part: Part.Shape, clearance: float | None = None, ) -> bool: """Check if a hole at this position would penetrate the outer surface. The safety check ensures that a hole with the specified clearance around it won't break through the outer walls of the part. Args: center: Center point of hole on cut surface direction: Direction of hole (into the part) part: Part shape to check against clearance: Optional clearance to use for safety check. If not provided, uses the minimum clearance from params. Returns: True if hole is safe, False if it would penetrate """ diameter = self.params["diameter"] depth = self.params["depth"] if clearance is None: clearance = self.params["clearance_min"] # Normalize direction dir_normalized = App.Vector(direction).normalize() # Create a test cylinder that represents the hole + clearance margin # Start the test cylinder slightly INSIDE the part to avoid the cut face # boundary issue (the test should check if the hole fits within the # solid material, not including the cut face surface itself) radius_check = (diameter / 2) + clearance start_offset = 0.5 # Start slightly inside the part # Position the test cylinder to start inside the part start_pos = center + (dir_normalized * start_offset) test_length = depth - start_offset # Reduce length accordingly # Only do the check if we have enough depth if test_length <= 0: return True # Hole is very shallow, assume safe # Create test cylinder test_cylinder = Part.makeCylinder( radius_check, test_length, start_pos, dir_normalized ) # Check if cylinder is fully contained within the part try: intersection = part.common(test_cylinder) # If intersection volume is significantly less than cylinder volume, # the hole would break through the outer surface cylinder_vol = test_cylinder.Volume intersection_vol = intersection.Volume # Allow 5% tolerance for floating point errors and minor surface irregularities if intersection_vol < cylinder_vol * 0.95: return False return True except Exception: # If boolean operation fails, consider it unsafe return False def generate_hole_positions( self, cut_face_center: App.Vector, cut_face: Part.Face ) -> tuple[list[App.Vector], Part.Wire, float, list[float]]: """Generate hole positions evenly distributed along the perimeter of the cut face. Instead of a grid pattern, this distributes N holes evenly along the outer edge(s) of the cut face. This works better for magnet holes that need to align when parts are joined. Uses the preferred clearance for initial hole placement. If holes fail safety checks, the repositioning logic will try clearances down to minimum. Args: cut_face_center: Center of the cut face cut_face: The cut face geometry Returns: Tuple of: - List of hole center positions - The outer wire (perimeter) of the cut face - Total perimeter length - List of original perimeter parameters for each position """ hole_count = self.params["hole_count"] # Use preferred clearance for initial placement clearance = self.params["clearance_preferred"] diameter = self.params["diameter"] # Get the outer wire (perimeter) of the cut face # For faces with holes (like ring shapes), there may be multiple wires # The outer wire is typically the longest one wires = cut_face.Wires if not wires: App.Console.PrintError("Cut face has no wires (edges)\n") return [], None, 0, [] # Find the outer wire (longest perimeter) outer_wire = max(wires, key=lambda w: w.Length) perimeter_length = outer_wire.Length App.Console.PrintMessage( f"Cut face perimeter length: {perimeter_length:.2f} mm\n" ) # Calculate the inset distance from the edge # Holes should be placed inward from the edge by clearance + radius inset = clearance + (diameter / 2) # Get the normal vector for the cut plane normal, _ = self.get_cut_plane_normal_and_point() normal = App.Vector(normal).normalize() # Distribute holes evenly along the perimeter # Calculate spacing between holes if hole_count < 1: return [], outer_wire, perimeter_length, [] # For N holes distributed around a closed perimeter, the spacing between # adjacent holes (including wrap-around from last to first) equals # perimeter_length divided by hole_count. This ensures equal distance # between all holes, including first and last. spacing = perimeter_length / hole_count App.Console.PrintMessage( f"Placing {hole_count} holes with {spacing:.2f} mm spacing\n" ) positions = [] original_params = [] for i in range(hole_count): # Parameter along the wire (0 to perimeter_length) # Place holes evenly spaced with a small offset to avoid starting # exactly at position 0 (which is often a corner/vertex where # determining the inward direction can be problematic) # Offset by half the spacing so holes are centered in their segments param = (i * spacing) + (spacing / 2) # Wrap around if we exceed perimeter length if param >= perimeter_length: param = param - perimeter_length # Get the point on the edge at this parameter # We need to walk along the wire's edges edge_point = self._get_point_at_length(outer_wire, param) if edge_point is None: continue # Now we need to move this point INWARD from the edge # toward the center of the face (or the solid material for ring shapes) inset_point = self._get_inset_point(edge_point, cut_face, normal, inset) if inset_point: positions.append(inset_point) original_params.append(param) App.Console.PrintMessage(f"Generated {len(positions)} hole positions\n") return positions, outer_wire, perimeter_length, original_params def _get_point_at_length(self, wire: Part.Wire, length: float) -> App.Vector | None: """Get a point on the wire at a specific length along it. Args: wire: The wire to traverse length: Distance along the wire Returns: Point at that distance, or None if not found """ cumulative_length = 0.0 for edge in wire.Edges: edge_length = edge.Length if cumulative_length + edge_length >= length: # The point is on this edge # Calculate how far along this edge remaining = length - cumulative_length # Parameter is normalized (0 to 1) along the edge param = remaining / edge_length if edge_length > 0 else 0 # Get the point using edge parameter space # Edge parameters go from edge.FirstParameter to edge.LastParameter first_param = edge.FirstParameter last_param = edge.LastParameter edge_param = first_param + param * (last_param - first_param) try: point = edge.valueAt(edge_param) return App.Vector(point) except Exception: return None cumulative_length += edge_length # If we get here, length exceeded wire length (shouldn't happen with valid input) return None def _get_inset_point( self, edge_point: App.Vector, cut_face: Part.Face, normal: App.Vector, inset: float, ) -> App.Vector | None: """Get a point that is inset from the edge toward the face interior. For simple shapes, this moves toward the face center. For ring shapes, it moves toward the solid material. Args: edge_point: Point on the edge cut_face: The cut face normal: Normal vector of the cut plane inset: Distance to move inward Returns: Inset point on the face, or None if invalid """ # Get the center of mass of the face face_center = cut_face.CenterOfMass # Direction from edge point toward center (projected onto the plane) to_center = face_center - edge_point # Remove any component along the normal (project onto plane) to_center = to_center - normal * (to_center.dot(normal)) if to_center.Length < 0.001: # Edge point is at center, can't determine direction return None # Normalize the direction to_center_normalized = App.Vector(to_center).normalize() # Move inward by the inset distance inset_point = edge_point + (to_center_normalized * inset) # Verify the inset point is actually on the face # (important for ring shapes where center of mass may be in the hole) try: dist_info = cut_face.distToShape(Part.Vertex(inset_point)) dist = dist_info[0] if dist < 0.5: # Point is on or very close to the face closest_on_face = dist_info[1][0][0] return App.Vector(closest_on_face) else: # Point is not on the face - for ring shapes, the inset point # may land in the hole. Return the closest point on the face # from the already-computed dist_info. return App.Vector(dist_info[1][0][0]) except Exception as e: App.Console.PrintWarning(f"Failed to validate inset point: {e}\n") return None def _find_alternative_position( self, original_pos: App.Vector, bottom_part: Part.Shape, top_part: Part.Shape, bottom_cut_face: Part.Face, top_cut_face: Part.Face, outer_wire: Part.Wire, perimeter_length: float, original_param: float, normal: App.Vector, ) -> App.Vector | None: """Try to find an alternative hole position when the original fails safety check. This method checks BOTH parts to ensure the repositioned hole works for both the bottom and top pieces. Strategy: 1. Try reducing clearance from preferred toward minimum (at same position) 2. Try moving further inward from the edge (increased inset with preferred clearance) 3. Try positions along the perimeter in both directions Args: original_pos: The original position that failed bottom_part: The bottom part shape top_part: The top part shape bottom_cut_face: The bottom cut face top_cut_face: The top cut face outer_wire: The outer wire (perimeter) perimeter_length: Total perimeter length original_param: Original parameter along the perimeter normal: Normal vector of the cut plane Returns: Alternative position if found, None otherwise """ diameter = self.params["diameter"] clearance_preferred = self.params["clearance_preferred"] clearance_min = self.params["clearance_min"] # Build a list of clearances to try, from preferred down to minimum # We try: preferred, 75% toward min, 50% toward min, 25% toward min, min clearance_steps = [] if clearance_preferred > clearance_min: step_size = (clearance_preferred - clearance_min) / 4 for i in range(5): # 0=preferred, 4=min clearance_steps.append(clearance_preferred - (i * step_size)) else: clearance_steps = [clearance_min] def is_safe_for_both(pos: App.Vector, check_clearance: float) -> bool: """Check if position is safe for both bottom and top parts at given clearance.""" # Check bottom part (holes go in -normal direction) if not self.is_hole_safe(pos, -normal, bottom_part, check_clearance): return False # Check top part (holes go in +normal direction) if not self.is_hole_safe(pos, normal, top_part, check_clearance): return False return True # Strategy 1: Try reducing clearance at the SAME position # This keeps holes in their ideal locations when possible if original_param is not None: edge_point = self._get_point_at_length(outer_wire, original_param) if edge_point: for try_clearance in clearance_steps[ 1: ]: # Skip preferred, we already tried it inset = try_clearance + (diameter / 2) inset_pos = self._get_inset_point( edge_point, bottom_cut_face, normal, inset ) if inset_pos and is_safe_for_both(inset_pos, try_clearance): return inset_pos # Strategy 2: Try moving further inward from the edge (multiplied inset) # Using each clearance level if original_param is not None: edge_point = self._get_point_at_length(outer_wire, original_param) if edge_point: for try_clearance in clearance_steps: base_inset = try_clearance + (diameter / 2) for multiplier in [1.5, 2.0, 2.5, 3.0]: increased_inset = base_inset * multiplier inset_pos = self._get_inset_point( edge_point, bottom_cut_face, normal, increased_inset ) if inset_pos and is_safe_for_both(inset_pos, try_clearance): return inset_pos # Strategy 3: Try positions along the perimeter in both directions # Search up to 20% of segment length in each direction if original_param is not None and perimeter_length > 0: segment_length = perimeter_length / self.params["hole_count"] # Try offsets in both directions: +5%, +10%, +15%, +20%, -5%, -10%, etc. offsets = [] for pct in [0.05, 0.10, 0.15, 0.20]: offsets.append(segment_length * pct) offsets.append(-segment_length * pct) for offset in offsets: new_param = (original_param + offset) % perimeter_length edge_point = self._get_point_at_length(outer_wire, new_param) if edge_point is None: continue # Try different clearance levels and inset distances for try_clearance in clearance_steps: base_inset = try_clearance + (diameter / 2) for multiplier in [1.0, 1.5, 2.0, 2.5]: inset = base_inset * multiplier inset_pos = self._get_inset_point( edge_point, bottom_cut_face, normal, inset ) if inset_pos and is_safe_for_both(inset_pos, try_clearance): return inset_pos return None def _check_hole_overlap( self, positions: list[App.Vector], new_pos: App.Vector ) -> bool: """Check if a new hole position would overlap with existing holes. Holes must have at least one hole diameter of space between them. Args: positions: List of already accepted hole positions new_pos: The new position to check Returns: True if position is valid (no overlap), False if it would overlap """ diameter = self.params["diameter"] # Minimum distance = 2 * diameter (one hole width between holes) min_distance = diameter * 2 for existing_pos in positions: # Calculate distance in XY plane (on the cut face) dist = (new_pos - existing_pos).Length if dist < min_distance: return False return True def execute(self, progress_callback=None): """Execute the complete cutting and hole placement operation. This method: 1. Cuts the object along the specified plane 2. Creates PartDesign::Body objects for each half 3. Validates hole positions against BOTH parts (not just one) 4. Checks for minimum spacing between holes (2x diameter) 5. Creates PartDesign::Hole features in both parts Each major step is wrapped in a FreeCAD transaction, allowing users to undo individual steps via Edit → Undo in the GUI. Args: progress_callback: Optional callback function for progress updates Returns: Tuple of (bottom_body, top_body) - PartDesign::Body objects """ doc = App.ActiveDocument # Count cylindrical faces (holes) in a shape def count_cylindrical_faces(shape): count = 0 for face in shape.Faces: if face.Surface.__class__.__name__ == "Cylinder": count += 1 return count # Log detailed information about the source object App.Console.PrintMessage( f"\n{'=' * 60}\n" f"Starting cut operation on: {self.obj.Label} ({self.obj.Name})\n" f"Object type: {self.obj.TypeId}\n" f"Shape faces: {len(self.shape.Faces)}, volume: {self.shape.Volume:.2f}mm³\n" f"Cylindrical faces (existing holes): {count_cylindrical_faces(self.shape)}\n" ) # If cutting a PartDesign::Body, log its structure if hasattr(self.obj, "Group"): App.Console.PrintMessage( f"Body Group: {[f'{o.Name} ({o.TypeId})' for o in self.obj.Group]}\n" ) if hasattr(self.obj, "BaseFeature") and self.obj.BaseFeature: App.Console.PrintMessage(f"Body BaseFeature: {self.obj.BaseFeature.Name}\n") if hasattr(self.obj, "Tip") and self.obj.Tip: App.Console.PrintMessage( f"Body Tip: {self.obj.Tip.Name} ({self.obj.Tip.TypeId})\n" ) App.Console.PrintMessage(f"{'=' * 60}\n\n") if progress_callback: progress_callback(10, "Cutting object...") # Cut the object (returns Part.Shape objects) # Note: This is a pure geometry operation, no document changes yet bottom_shape, top_shape = self.cut_object() # Log cut results App.Console.PrintMessage( f"Cut results:\n" f" Bottom shape: {len(bottom_shape.Faces)} faces, " f"volume={bottom_shape.Volume:.2f}mm³, " f"cylindrical faces={count_cylindrical_faces(bottom_shape)}\n" f" Top shape: {len(top_shape.Faces)} faces, " f"volume={top_shape.Volume:.2f}mm³, " f"cylindrical faces={count_cylindrical_faces(top_shape)}\n" ) if progress_callback: progress_callback(25, "Finding cut faces...") # Get cut plane normal normal, _ = self.get_cut_plane_normal_and_point() # Find cut faces (on shapes, before converting to bodies) bottom_face_center = self.get_cut_face_center(bottom_shape, -normal) top_face_center = self.get_cut_face_center(top_shape, normal) if not bottom_face_center or not top_face_center: raise HolePlacementError("Could not find cut faces") if progress_callback: progress_callback(35, "Generating hole positions...") # Find the actual cut face from bottom part bottom_cut_face = None for face in bottom_shape.Faces: if face.CenterOfMass.distanceToPoint(bottom_face_center) < 0.1: bottom_cut_face = face break if not bottom_cut_face: raise HolePlacementError("Could not find bottom cut face") # Find the actual cut face from top part (for repositioning on top part) top_cut_face = None for face in top_shape.Faces: if face.CenterOfMass.distanceToPoint(top_face_center) < 0.1: top_cut_face = face break if not top_cut_face: raise HolePlacementError("Could not find top cut face") # Get the cut plane point for projecting existing holes _, cut_point = self.get_cut_plane_normal_and_point() # Project existing holes from previous cuts onto the new cut plane # These holes MUST be preserved to maintain magnet alignment existing_hole_positions = self._project_existing_holes_to_cut_plane( normal, cut_point ) # Generate NEW hole positions for this cut new_positions, outer_wire, perimeter_length, original_params = ( self.generate_hole_positions(bottom_face_center, bottom_cut_face) ) App.Console.PrintMessage( f"Hole positions: {len(existing_hole_positions)} existing + " f"{len(new_positions)} new\n" ) # Combine existing and new positions # Existing holes are mandatory - they maintain magnet alignment from previous cuts # New holes are added for this cut's magnet connections initial_positions = existing_hole_positions + new_positions if not initial_positions: raise HolePlacementError("No valid hole positions found") if progress_callback: progress_callback(45, "Validating hole positions on both parts...") # Validate each position against BOTH parts and check for overlap # This ensures holes are placed identically in both parts validated_positions = [] holes_repositioned = 0 holes_skipped = 0 num_existing = len(existing_hole_positions) # Use preferred clearance for initial validation clearance_preferred = self.params["clearance_preferred"] for idx, pos in enumerate(initial_positions): is_existing_hole = idx < num_existing # For new holes, get the original parameter for repositioning if not is_existing_hole: new_idx = idx - num_existing original_param = ( original_params[new_idx] if new_idx < len(original_params) else None ) else: original_param = None # Check if position is safe for both parts using preferred clearance bottom_safe = self.is_hole_safe( pos, -normal, bottom_shape, clearance_preferred ) top_safe = self.is_hole_safe(pos, normal, top_shape, clearance_preferred) final_pos = None if bottom_safe and top_safe: # Position is good for both parts final_pos = pos elif is_existing_hole: # Existing holes should be preserved IF they pass minimum clearance # If they fail even minimum clearance, they would break the wall clearance_min = self.params["clearance_min"] bottom_safe_min = self.is_hole_safe( pos, -normal, bottom_shape, clearance_min ) top_safe_min = self.is_hole_safe(pos, normal, top_shape, clearance_min) if bottom_safe_min and top_safe_min: final_pos = pos App.Console.PrintWarning( f"Existing hole {idx + 1} at ({pos.x:.2f}, {pos.y:.2f}) " f"uses minimum clearance\n" ) else: # Existing hole would break through wall - skip it # This happens when cutting through a face that had holes, # and some holes are now outside the new cut face boundary App.Console.PrintWarning( f"Skipping existing hole {idx + 1} at ({pos.x:.2f}, {pos.y:.2f}) " f"- would break through outer wall (outside cut face boundary)\n" ) holes_skipped += 1 continue else: # Try to find an alternative position that works for both alternative = self._find_alternative_position( pos, bottom_shape, top_shape, bottom_cut_face, top_cut_face, outer_wire, perimeter_length, original_param, normal, ) if alternative: final_pos = alternative holes_repositioned += 1 App.Console.PrintMessage( f"Repositioned new hole {idx + 1} from ({pos.x:.2f}, {pos.y:.2f}) " f"to ({alternative.x:.2f}, {alternative.y:.2f})\n" ) if final_pos: # Check for overlap with already validated positions # But existing holes always get added (they're mandatory) if is_existing_hole or self._check_hole_overlap( validated_positions, final_pos ): validated_positions.append(final_pos) else: holes_skipped += 1 App.Console.PrintWarning( f"Skipping new hole at ({final_pos.x:.2f}, {final_pos.y:.2f}) " f"- too close to another hole (need {self.params['diameter'] * 2:.1f}mm spacing)\n" ) else: holes_skipped += 1 App.Console.PrintWarning( f"Skipping hole {idx + 1} at ({pos.x:.2f}, {pos.y:.2f}) " f"- could not find safe position for both parts\n" ) if not validated_positions: raise HolePlacementError("No valid hole positions found after validation") App.Console.PrintMessage( f"Validated {len(validated_positions)} hole positions " f"({holes_repositioned} repositioned, {holes_skipped} skipped)\n" ) if progress_callback: progress_callback(55, "Creating PartDesign bodies...") # Transaction 1: Create bottom body from cut shape doc.openTransaction("Create Bottom Body") try: bottom_body = self._create_body_from_shape( bottom_shape, f"{self.obj.Label}_Bottom" ) doc.commitTransaction() except Exception: doc.abortTransaction() raise # Transaction 2: Create top body from cut shape doc.openTransaction("Create Top Body") try: top_body = self._create_body_from_shape(top_shape, f"{self.obj.Label}_Top") doc.commitTransaction() except Exception: doc.abortTransaction() raise if progress_callback: progress_callback(65, "Finding cut faces on bodies...") # Find cut face names on the new bodies # Note: Face normals point OUTWARD from each solid piece: # - Bottom piece's cut face normal points toward top (same as plane normal) # - Top piece's cut face normal points toward bottom (opposite to plane normal) bottom_face_name = self._find_cut_face_name(bottom_body, normal) top_face_name = self._find_cut_face_name(top_body, -normal) App.Console.PrintMessage( f"Cut faces: bottom={bottom_face_name}, top={top_face_name}\n" ) if progress_callback: progress_callback(75, "Creating hole sketch for bottom part...") # Transaction 3: Create hole sketch for bottom body doc.openTransaction("Create Bottom Hole Sketch") try: bottom_sketch = self._create_hole_sketch( bottom_body, bottom_face_name, validated_positions ) doc.commitTransaction() except Exception: doc.abortTransaction() raise if progress_callback: progress_callback(82, "Creating hole sketch for top part...") # Transaction 4: Create hole sketch for top body doc.openTransaction("Create Top Hole Sketch") try: top_sketch = self._create_hole_sketch( top_body, top_face_name, validated_positions ) doc.commitTransaction() except Exception: doc.abortTransaction() raise if progress_callback: progress_callback( 88, f"Creating {len(validated_positions)} holes in bottom part..." ) # Transaction 5: Create hole feature in bottom body doc.openTransaction("Create Bottom Magnet Holes") try: self._create_hole_feature( bottom_body, bottom_sketch, self.params["diameter"], self.params["depth"], ) doc.commitTransaction() except Exception: doc.abortTransaction() raise if progress_callback: progress_callback(95, "Creating holes in top part...") # Transaction 6: Create hole feature in top body doc.openTransaction("Create Top Magnet Holes") try: self._create_hole_feature( top_body, top_sketch, self.params["diameter"], self.params["depth"] ) doc.commitTransaction() except Exception: doc.abortTransaction() raise if progress_callback: progress_callback(96, "Separating cut parts...") # Transaction 7: Move top body away from bottom body (100mm separation) doc.openTransaction("Separate Cut Parts") try: # Move the top body along the cut plane normal direction # This creates a 100mm gap between the cut faces separation_distance = 100.0 # mm offset_vector = App.Vector( normal.x * separation_distance, normal.y * separation_distance, normal.z * separation_distance, ) # Get current placement and add offset current_placement = top_body.Placement new_base = current_placement.Base + offset_vector top_body.Placement = App.Placement( new_base, current_placement.Rotation, App.Vector(0, 0, 0) ) doc.commitTransaction() App.Console.PrintMessage( f"Separated parts by {separation_distance}mm along cut normal\n" ) except Exception as e: doc.abortTransaction() App.Console.PrintWarning(f"Could not separate parts: {e}\n") if progress_callback: progress_callback(98, "Hiding original objects...") # Transaction 8: Hide original object and cutting plane doc.openTransaction("Hide Original Objects") try: # Hide the original object if hasattr(self.obj, "ViewObject") and self.obj.ViewObject: self.obj.ViewObject.Visibility = False # Hide the cutting plane if it's a model plane if self.params.get("plane_type") == "Model Plane": model_plane = self.params.get("model_plane") if model_plane and len(model_plane) >= 2: plane_obj = model_plane[1] if hasattr(plane_obj, "ViewObject") and plane_obj.ViewObject: plane_obj.ViewObject.Visibility = False doc.commitTransaction() except Exception: # Don't fail the whole operation if hiding fails doc.abortTransaction() App.Console.PrintWarning( "Could not hide original objects (GUI may not be available)\n" ) if progress_callback: progress_callback(100, "Complete!") return bottom_body, top_body def _create_body_from_shape(self, shape: Part.Shape, name: str): """Create a PartDesign::Body containing the given shape. Uses Body.BaseFeature property to wrap an existing shape, allowing PartDesign features (like Hole) to be added to imported/boolean geometry. Args: shape: The Part.Shape to wrap name: Name for the new body Returns: The created PartDesign::Body object """ doc = App.ActiveDocument # Log diagnostic information about the input shape App.Console.PrintMessage( f"Creating body '{name}' from shape with {len(shape.Faces)} faces, " f"volume={shape.Volume:.2f}mm³\n" ) # First create a Part::Feature to hold the shape # This is needed because BaseFeature references a document object, not a raw shape base_feature_name = f"{name}_Base" feature = doc.addObject("Part::Feature", base_feature_name) feature.Shape = shape # Create PartDesign::Body body = doc.addObject("PartDesign::Body", name) # Set the BaseFeature property to reference the Part::Feature # Note: This is a property, not created via newObject() body.BaseFeature = feature # Hide the intermediate Part::Feature (it's now part of the body) if hasattr(feature, "ViewObject") and feature.ViewObject: feature.ViewObject.Visibility = False doc.recompute() # Log the created body structure App.Console.PrintMessage( f"Created body '{name}': BaseFeature={body.BaseFeature.Name if body.BaseFeature else 'None'}, " f"Group={[obj.Name for obj in body.Group]}\n" ) return body def _get_internal_base_feature(self, body): """Get the internal PartDesign::FeatureBase from a body. When you set body.BaseFeature = some_part_feature, FreeCAD creates an internal PartDesign::FeatureBase in body.Group. This internal feature is what sketches should be attached to, not the external Part::Feature. Args: body: The PartDesign::Body to search Returns: The PartDesign::FeatureBase object Raises: HolePlacementError: If no FeatureBase is found """ for obj in body.Group: if obj.TypeId == "PartDesign::FeatureBase": return obj raise HolePlacementError( f"Body {body.Label} has no PartDesign::FeatureBase in Group" ) def _find_cut_face_name(self, body, normal: App.Vector) -> str: """Find the name of the cut face on a PartDesign::Body's internal FeatureBase. For planar cuts on flat objects, this searches for faces with matching normals. For curved objects (like vases), it finds the largest face whose center lies closest to the cut plane. Note: We use the internal PartDesign::FeatureBase (from body.Group) because: 1. It's the stable internal representation of the imported shape 2. Sketches must be attached to PartDesign features, not Part::Feature 3. body.Tip might be a failed Hole feature from a previous run 4. body.BaseFeature is the external Part::Feature, not suitable for sketch attachment Args: body: The PartDesign::Body to search normal: Expected normal direction of the cut face Returns: Face name string like "Face1", "Face2", etc. Raises: HolePlacementError: If no matching face is found """ # Get the internal PartDesign::FeatureBase - this is what sketches attach to base_feature = self._get_internal_base_feature(body) shape = base_feature.Shape # Normalize the target normal target_normal = App.Vector(normal).normalize() # Get the cut plane point _, cut_point = self.get_cut_plane_normal_and_point() # Find candidates: faces with matching normal AND close to cut plane # This handles both: # 1. Fresh cuts (single matching face) # 2. Re-cuts of already-cut objects (multiple planar faces, need the NEW one) candidates = [] for i, face in enumerate(shape.Faces): try: face_normal = face.normalAt(0.5, 0.5) dot = face_normal.dot(target_normal) # Skip faces with wrong normal direction if dot < 0.3: continue # Calculate distance from face center to the cut plane face_center = face.CenterOfMass dist_to_plane = abs((face_center - cut_point).dot(target_normal)) candidates.append( { "index": i, "dist": dist_to_plane, "dot": dot, "area": face.Area, "surface_type": face.Surface.__class__.__name__, } ) except Exception: continue if not candidates: raise HolePlacementError( f"Could not find any face on body {body.Label} with normal " f"matching the cut plane direction" ) # Strategy 1: Look for planar faces with exact normal match AND close to cut plane # This is the ideal case - a flat face created by the current cut planar_matches = [ c for c in candidates if c["surface_type"] == "Plane" and c["dot"] > 0.99 and c["dist"] < 5.0 ] # Log all planar matches for debugging if planar_matches: App.Console.PrintMessage( f"Found {len(planar_matches)} planar face candidates close to cut plane:\n" ) for m in planar_matches[:5]: # Show up to 5 App.Console.PrintMessage( f" Face{m['index'] + 1}: dist={m['dist']:.2f}mm, " f"dot={m['dot']:.3f}, area={m['area']:.1f}mm²\n" ) if planar_matches: # Sort by distance to plane (closest first), then by area (largest first) planar_matches.sort(key=lambda x: (x["dist"], -x["area"])) best = planar_matches[0] App.Console.PrintMessage( f"Selected cut face (planar, exact match): Face{best['index'] + 1} " f"(dist={best['dist']:.2f}mm, dot={best['dot']:.3f}, " f"area={best['area']:.1f}mm²)\n" ) return f"Face{best['index'] + 1}" # Strategy 2: Look for any face with good normal match close to the cut plane # This handles curved objects where cut face might not be perfectly planar close_matches = [c for c in candidates if c["dist"] < 5.0 and c["dot"] > 0.5] if close_matches: # Sort by dot product (best match first), then distance, then area close_matches.sort(key=lambda x: (-x["dot"], x["dist"], -x["area"])) best = close_matches[0] App.Console.PrintMessage( f"Found cut face (close to plane): Face{best['index'] + 1} " f"(dist={best['dist']:.2f}mm, dot={best['dot']:.3f}, " f"area={best['area']:.1f}mm², type={best['surface_type']})\n" ) return f"Face{best['index'] + 1}" # Strategy 3: Fallback - best dot product match regardless of distance # This might pick a face from a previous cut, but it's better than failing candidates.sort(key=lambda x: (-x["dot"], x["dist"], -x["area"])) best = candidates[0] App.Console.PrintWarning( f"Warning: No face close to cut plane found. Using best normal match: " f"Face{best['index'] + 1} (dist={best['dist']:.2f}mm, dot={best['dot']:.3f})\n" ) return f"Face{best['index'] + 1}" def _world_to_sketch_coords(self, world_pos: App.Vector, sketch) -> App.Vector: """Transform world coordinates to sketch-local 2D coordinates. Sketches use a local 2D coordinate system. This transforms a 3D world position to the corresponding 2D position in the sketch plane. Args: world_pos: Position in world (document) coordinates sketch: The Sketcher::SketchObject with placement info Returns: Position in sketch-local coordinates (Z should be ~0) """ # Get sketch placement (transforms sketch coords to world) placement = sketch.Placement # Inverse transform: world to sketch local inv_placement = placement.inverse() local_pos = inv_placement.multVec(world_pos) # Return 2D (Z should be ~0 for points on the sketch plane) return App.Vector(local_pos.x, local_pos.y, 0) def _create_hole_sketch( self, body, cut_face_name: str, positions: list[App.Vector] ): """Create a sketch with points at hole center positions. The sketch is attached to the cut face and contains points that will be used as hole centers for the PartDesign::Hole feature. Args: body: The PartDesign::Body to add the sketch to cut_face_name: Name of the face to attach the sketch to positions: List of hole center positions in world coordinates Returns: The created Sketcher::SketchObject """ # Create sketch attached to cut face on the internal PartDesign::FeatureBase # Sketches must reference a PartDesign feature (not Part::Feature), not the body sketch = body.newObject("Sketcher::SketchObject", "HoleCenters") # Get the internal PartDesign::FeatureBase (not body.BaseFeature which is Part::Feature) # This is the stable internal representation that sketches can attach to base_feature = self._get_internal_base_feature(body) # AttachmentSupport format: list of (feature, [face_names]) # Note: In FreeCAD 1.0+, use AttachmentSupport instead of deprecated Support sketch.AttachmentSupport = [(base_feature, cut_face_name)] sketch.MapMode = "FlatFace" # Recompute to establish sketch placement App.ActiveDocument.recompute() # Add point at each hole position # Points need to be in sketch-local coordinates for pos in positions: local_pos = self._world_to_sketch_coords(pos, sketch) sketch.addGeometry( Part.Point(App.Vector(local_pos.x, local_pos.y, 0)), False, # Not construction geometry ) App.ActiveDocument.recompute() return sketch def _create_hole_feature(self, body, sketch, diameter: float, depth: float): """Create a PartDesign::Hole feature from a sketch with point geometry. The Hole feature creates cylindrical holes at each point in the sketch. These holes are parametric and can be edited after creation. Args: body: The PartDesign::Body containing the sketch sketch: Sketch with points defining hole centers diameter: Hole diameter in mm depth: Hole depth in mm Returns: The created PartDesign::Hole feature """ hole = body.newObject("PartDesign::Hole", "MagnetHoles") hole.Profile = sketch hole.Diameter = diameter hole.Depth = depth hole.DepthType = "Dimension" # Fixed depth (not "ThroughAll") hole.Threaded = False hole.HoleCutType = "None" # Simple hole (no countersink/counterbore) App.ActiveDocument.recompute() # Validate the hole feature was created successfully if hasattr(hole, "isValid") and callable(hole.isValid): is_valid = hole.isValid() else: # Check if the shape has non-zero volume as a proxy for validity is_valid = hasattr(hole, "Shape") and hole.Shape.Volume > 0 App.Console.PrintMessage( f"Created hole feature '{hole.Name}' on body '{body.Label}': " f"valid={is_valid}, " f"profile={sketch.Name}, " f"diameter={diameter}mm, depth={depth}mm\n" ) # Log body shape info after hole creation if hasattr(body, "Shape"): App.Console.PrintMessage( f"Body '{body.Label}' after holes: " f"{len(body.Shape.Faces)} faces, " f"volume={body.Shape.Volume:.2f}mm³\n" ) return hole def _create_holes_boolean( self, part: Part.Shape, direction: App.Vector, positions: list[App.Vector] ) -> Part.Shape: """Create holes using boolean operations (fallback method). This is the original hole creation method using Part.makeCylinder and boolean cut operations. Kept as fallback if PartDesign::Hole fails for certain geometry types. Args: part: Part shape to add holes to direction: Direction of holes (pointing INTO the part) positions: List of validated hole positions Returns: Part with holes cut """ diameter = self.params["diameter"] depth = self.params["depth"] # Normalize direction vector dir_normalized = App.Vector(direction).normalize() result = part holes_created = 0 for pos in positions: # Create hole - start slightly OUTSIDE the part (offset back from cut face) # so the boolean cut operation works correctly offset = 0.1 start_pos = pos - (dir_normalized * offset) hole_length = depth + offset try: hole = Part.makeCylinder( diameter / 2, hole_length, start_pos, dir_normalized ) result = result.cut(hole) holes_created += 1 except Exception as e: App.Console.PrintWarning( f"Failed to create hole at ({pos.x:.2f}, {pos.y:.2f}): {e!s}\n" ) App.Console.PrintMessage(f"Created {holes_created} holes\n") return result def _is_plane_object(obj) -> bool: """Check if an object is a datum plane or has a planar face.""" if hasattr(obj, "TypeId"): if "Plane" in obj.TypeId: return True return False def _get_object_type(obj) -> str: """Get a human-readable type description for an object.""" if hasattr(obj, "TypeId"): type_id = obj.TypeId if "Part::" in type_id: return type_id.replace("Part::", "") if "PartDesign::" in type_id: return type_id.replace("PartDesign::", "") if "Mesh::" in type_id: return "Mesh" return type_id if hasattr(obj, "Shape"): return "Shape" return "" def main(): """Main macro entry point.""" # Check for active document if not App.ActiveDocument: QtGui.QMessageBox.warning( None, "No Document", "Please open or create a document first." ) return # Get current selection to use as defaults selection = Gui.Selection.getSelection() # Build a map of BaseFeature -> Body for resolving intermediate objects base_to_body = {} for obj in App.ActiveDocument.Objects: if hasattr(obj, "TypeId") and obj.TypeId == "PartDesign::Body": if hasattr(obj, "BaseFeature") and obj.BaseFeature: base_to_body[obj.BaseFeature.Name] = obj # Determine default object and plane from selection default_obj = None selected_plane = None for sel_obj in selection: if _is_plane_object(sel_obj): selected_plane = sel_obj elif hasattr(sel_obj, "Shape") and not default_obj: # Check if this is actually a BaseFeature of a Body # If so, use the Body instead if sel_obj.Name in base_to_body: default_obj = base_to_body[sel_obj.Name] # Skip hidden objects and _Base suffixed objects elif sel_obj.Name.endswith("_Base") or sel_obj.Label.endswith("_Base"): # Try to find the corresponding body body_name = sel_obj.Name.replace("_Base", "") body = App.ActiveDocument.getObject(body_name) if body and hasattr(body, "Shape"): default_obj = body elif hasattr(sel_obj, "ViewObject") and sel_obj.ViewObject: if sel_obj.ViewObject.Visibility: default_obj = sel_obj else: default_obj = sel_obj # Show dialog - user can select/change object in the dialog dialog = CutObjectForMagnetsDialog() # Set the default object (from selection) if we found one if default_obj: dialog.set_selected_object(default_obj.Label) # If a plane was selected, set it as the default cut plane if selected_plane: dialog.set_default_plane(selected_plane.Label) if dialog.exec_() != QtGui.QDialog.Accepted: return # Get the object selected in the dialog (user may have changed it) obj = dialog.get_selected_object() if obj is None: QtGui.QMessageBox.warning( None, "No Object Selected", "Please select an object to cut." ) return params = dialog.get_parameters() # Validate model plane selection if params["plane_type"] == "Model Plane": if not params["model_plane"]: QtGui.QMessageBox.warning( None, "No Plane Selected", "Please select a model plane or switch to preset plane mode.", ) return if dialog.model_plane_combo.currentText() == "No planes available": QtGui.QMessageBox.warning( None, "No Planes Available", "No datum planes or planar faces found in the document.\n\n" "Create a datum plane (Part Design → Create datum plane) or\n" "switch to preset plane mode.", ) return try: # Create cutter with the object selected in the dialog cutter = SmartCutter(obj, params) # Execute with progress updates def progress_update(value, message=""): dialog.set_status(message) dialog.set_progress(value) bottom_body, top_body = cutter.execute(progress_update) # Bodies are already created in the document by execute() # Original object is hidden in execute() Transaction 8 App.ActiveDocument.recompute() dialog.set_status( f"Success! Created {bottom_body.Label} and {top_body.Label}\n" f"Original object hidden. Holes are parametric - edit them in the feature tree." ) App.Console.PrintMessage( f"Cut complete: {bottom_body.Label}, {top_body.Label}\n" f"Holes created as PartDesign::Hole features (editable in feature tree)\n" ) except HolePlacementError as e: dialog.set_status(f"Error: {e!s}", is_error=True) App.Console.PrintError(f"Cut failed: {e!s}\n") except Exception as e: dialog.set_status(f"Unexpected error: {e!s}", is_error=True) App.Console.PrintError(f"Unexpected error: {e!s}\n") import traceback traceback.print_exc() if __name__ == "__main__": main()