* fix: version in wiki-source.txt fix * chore: small release-fixes * chore: prep for 0.6.1 release
1932 lines
74 KiB
Plaintext
1932 lines
74 KiB
Plaintext
"""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.1"
|
|
__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()
|