* ci: add caching and fix workflow failures - Add UV package caching to all workflows for faster dependency installation - Add pre-commit hook caching with OS-specific cache keys - Skip no-commit-to-branch hook in CI (fails on main branch) - Remove broken apt cache action (doesn't work with PPAs) - Simplify FreeCAD command detection (PPA installs to standard PATH) - Add FreeCAD Python version logging for debugging * ci: Add code Rabbit configuration file * ci: fix issues in CI workflows * ci: add uv.lock * ci: tweaks * ci: Fix errors and add UUID generation and checking * ci: Use the GitHub FreeCAD release latest stables * ci: skip macro test for now, due to headless mode * feat: Add a multi export macro * ci: Fix tests * ci: fix docker build workflow * ci: skip trufflehog in GitHub Actions due to wasm panic bug TruffleHog has a known wasm/go-re2 panic bug that causes failures in GitHub Actions environment. The hook still runs locally during development for secrets detection. - Add trufflehog to SKIP env var in pre-commit.yaml - Update trufflehog to v3.88.7 (latest) - Add reference to upstream issue #3321 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: use boolean holes instead of PartDesign::Hole in CI PartDesign::Hole has a CADKernelError bug in FreeCAD AppImage headless mode on Linux (used in GitHub Actions) where it fails with "Cannot make face from profile". The SmartCutter class already supports boolean holes as an alternative. Changes: - Modify SmartCutter.execute() to use boolean holes by default - Update all test assertions for Part::Feature output type - Update test docstrings and class descriptions - Remove PartDesign-specific checks (Group, Sketcher::SketchObject) - Update workflow comment explaining the CI limitation Boolean holes work reliably in both GUI and headless mode across all FreeCAD configurations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ci: clean up GitHub Actions workflows * ci: Dependabot improvements * ci: add CodeQL scanning workflow * ci: AI review suggested improvements * ci: add coderabbit updates for intentional decisions --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1927 lines
74 KiB
Plaintext
1927 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.
|
|
|
|
Version: 1.0.0
|
|
|
|
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"
|
|
"""
|
|
|
|
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, we may need
|
|
# to adjust. Try projecting directly onto the face.
|
|
dist_info = cut_face.distToShape(Part.Vertex(inset_point))
|
|
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()
|
|
# Just need to finalize and hide original
|
|
|
|
# Transaction 7: Hide original object
|
|
App.ActiveDocument.openTransaction("Hide Original Object")
|
|
try:
|
|
obj.ViewObject.Visibility = False
|
|
App.ActiveDocument.commitTransaction()
|
|
except Exception:
|
|
App.ActiveDocument.abortTransaction()
|
|
raise
|
|
|
|
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()
|