Files
freecad-robust-mcp-fc111/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro
T
8c338f6da7 feat: MCP Bridge Workbench, just command cleanup, testing, etc. (#24)
* fix: lots of fixes and name refactoring

* feat: Add workbench preferences

* fix: MCP bridge status widget and just command fixes

* fix(tests): Use the correct mesa-glx package

* fix(ci): Add fontconfig to GUI test dependencies

FreeCAD GUI was failing to start with:
"Fontconfig error: Cannot load default config file: No such file"

Added fontconfig and fonts-dejavu-core packages to the GUI test job
dependencies to resolve the font configuration issue.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(addon): Extract path utilities into shared module

Create path_utils.py module that consolidates duplicated path-finding
logic from commands.py and InitGui.py:
- get_addon_path(): Find addon directory with caching and fallbacks
- get_icon_path(): Get full path to an icon file
- get_icons_dir(): Get path to icons directory
- get_workbench_icon(): Get path to workbench main icon

This removes ~100 lines of duplicated code while preserving the same
behavior including _addon_path_cache and all fallback methods.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(addon): Prevent stale plugin state on startup failure

The StartMCPBridgeCommand.Activated method could leave _mcp_plugin in
a partially initialized state if FreecadMCPPlugin.start() failed after
the plugin was instantiated.

Changes:
- Create plugin in a local variable first
- Only assign to _mcp_plugin after start() succeeds
- Explicitly clear _mcp_plugin and _running_config in exception
  handlers to ensure clean state for subsequent retry attempts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Lot of broad improvements

* fix(ci): Use blocking headless_server.py for GUI tests

The GUI test was using startup_bridge.py which is non-blocking
(designed for interactive use). For CI, even in GUI mode, we need
the blocking headless_server.py that calls run_forever() to keep
FreeCAD running. GUI features are still available since we use
the 'freecad' executable instead of 'freecadcmd'.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(addon): Rename headless_server.py to blocking_bridge.py

The old name was misleading because:
- It works with both GUI (freecad) and headless (freecadcmd) modes
- The key characteristic is that it BLOCKS with run_forever()

New naming convention clarifies the difference:
- blocking_bridge.py: Starts bridge and blocks (for CI, servers)
- startup_bridge.py: Starts bridge and returns (for interactive GUI)

Updated all references across:
- GitHub workflow (macro-test.yaml)
- Just commands (freecad.just)
- Unit tests (test_addon_structure.py)
- Documentation (5 files)
- CLAUDE.md

Also improved the script to detect GUI mode dynamically using
FreeCAD.GuiUp and display the appropriate status message.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(just): Remove erroneous rm of startup_bridge.py on error

The startup script is now a permanent source file in the repository,
not a generated temporary file. The rm -f would have deleted source
code if FreeCAD wasn't found.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: General improvements

* fix: Lots of general fixes and only stable to PyPi

* fix: small cleanup

* fix: Small fixes and hopefully fixes the GUI tests

* fix: Add proper library paths for FreeCAD GUI in CI

- Create wrapper scripts instead of symlinks for AppImage binaries
- Set LD_LIBRARY_PATH, QT_PLUGIN_PATH for GUI mode
- Add diagnostic output to identify startup failures

* fix: Use apprun for GUI tests in CI

* fix: Improving Xvfb tests

* fix: GUI tests worlk

* chore: remove invalid --no-splash comments

* fix: ARM64 architecture support and other fixes

* fix: cleanup

* test: just commands test suite

* test: improve just command tests

* fix: more general improvements

* fix: more cleanup

* fix: more updates

* fix: small tweaks

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 15:26:33 -08:00

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.5.0-beta"
__Date__ = "2026-01-05"
__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()