CAM: Avoid bosses when pocketing. (#24723)

This commit is contained in:
sliptonic
2025-12-15 12:29:28 -06:00
committed by GitHub
parent 12355f37e9
commit d116764dc3
2 changed files with 104 additions and 28 deletions

View File

@@ -238,10 +238,10 @@ The latter can be used to face of the entire stock area to ensure uniform height
<item row="1" column="1">
<widget class="QCheckBox" name="useOutline">
<property name="toolTip">
<string>If selected the operation uses the outline of the selected base geometry and ignores all holes and islands</string>
<string>If selected the operation uses the outline of the selected base geometry and ignores all holes</string>
</property>
<property name="text">
<string>Use outline</string>
<string>Ignore holes</string>
</property>
</widget>
</item>

View File

@@ -57,6 +57,75 @@ class ObjectPocket(PathPocketBase.ObjectPocket):
def areaOpFeatures(self, obj):
return super(self.__class__, self).areaOpFeatures(obj) | PathOp.FeatureLocations
def removeHoles(self, solid, face, tolerance=1e-6):
"""removeHoles(solid, face, tolerance) ... Remove hole wires from a face, keeping outer wire and boss wires.
Uses a cross-section algorithm: sections the solid slightly above the face level.
Wires that appear in the section are bosses (material above).
Wires that don't appear are holes (voids).
Args:
solid: The parent solid object
face: The face to process
tolerance: Distance tolerance for comparisons
Returns:
New face with outer wire and boss wires only
"""
outer_wire = face.OuterWire
candidate_wires = [w for w in face.Wires if not w.isSame(outer_wire)]
if not candidate_wires:
return face
boss_wires = []
try:
# Create cutting plane from outer wire, offset above face by tolerance
cutting_plane = Part.Face(outer_wire)
cutting_plane.translate(FreeCAD.Vector(0, 0, tolerance))
# Section the solid
section = solid.Shape.section(cutting_plane)
if hasattr(section, "Edges") and section.Edges:
# Translate section edges back to face level
translated_edges = []
for edge in section.Edges:
translated_edge = edge.copy()
translated_edge.translate(FreeCAD.Vector(0, 0, -tolerance))
translated_edges.append(translated_edge)
# Build closed wires from edges
edge_groups = Part.sortEdges(translated_edges)
all_section_wires = []
for edge_list in edge_groups:
try:
wire = Part.Wire(edge_list)
if wire.isClosed():
all_section_wires.append(wire)
except Exception:
# ignore any wires that can't be built
pass
# Filter out outer wire, keep remaining as boss wires
for wire in all_section_wires:
if not wire.isSame(outer_wire):
length_diff = abs(wire.Length - outer_wire.Length)
if length_diff > tolerance:
boss_wires.append(wire)
except Exception as e:
Path.Log.error("removeHoles: Section algorithm failed: {}".format(e))
boss_wires = candidate_wires
# Construct new face with outer wire and boss wires
wire_compound = Part.makeCompound([outer_wire] + boss_wires)
new_face = Part.makeFace(wire_compound, "Part::FaceMakerBullseye")
return new_face
def initPocketOp(self, obj):
"""initPocketOp(obj) ... setup receiver"""
if not hasattr(obj, "UseOutline"):
@@ -106,15 +175,20 @@ class ObjectPocket(PathPocketBase.ObjectPocket):
for base, subList in obj.Base:
for sub in subList:
if "Face" in sub:
if sub not in avoidFeatures and not self.clasifySub(base, sub):
if sub not in avoidFeatures and not self.classifySub(base, sub):
Path.Log.error(
"Pocket does not support shape {}.{}".format(base.Label, sub)
)
# Convert horizontal faces to use outline only if requested
Path.Log.debug("UseOutline: {}".format(obj.UseOutline))
Path.Log.debug("self.horiz: {}".format(self.horiz))
if obj.UseOutline and self.horiz:
horiz = [Part.Face(f.Wire1) for f in self.horiz]
horiz = [self.removeHoles(base, face) for (face, base) in self.horiz]
self.horiz = horiz
else:
# Extract just the faces from the tuples for further processing
self.horiz = [face for (face, base) in self.horiz]
# Check if selected vertical faces form a loop
if len(self.vert) > 0:
@@ -133,6 +207,7 @@ class ObjectPocket(PathPocketBase.ObjectPocket):
self.horiz.append(face)
# Add faces for extensions
# Note: Extension faces don't have a parent base object, so we append them directly
self.exts = []
for ext in extensions:
if not ext.avoid:
@@ -198,46 +273,49 @@ class ObjectPocket(PathPocketBase.ObjectPocket):
return True
return False
def clasifySub(self, bs, sub):
"""clasifySub(bs, sub)...
def classifySub(self, bs, sub):
"""classifySub(bs, sub)...
Given a base and a sub-feature name, returns True
if the sub-feature is a horizontally oriented flat face.
if the sub-feature is a horizontally or vertically oriented flat face.
"""
face = bs.Shape.getElement(sub)
if isinstance(face.Surface, Part.BSplineSurface):
Path.Log.debug("face Part.BSplineSurface")
if Path.Geom.isRoughly(face.BoundBox.ZLength, 0):
Path.Log.debug(" flat horizontal or almost flat horizontal")
self.horiz.append(face)
return True
elif isinstance(face.Surface, Part.Plane):
Path.Log.debug("face Part.Plane")
if Path.Geom.isRoughly(abs(face.Surface.Axis.z), 1, 0.001):
Path.Log.debug(" flat horizontal or almost flat horizontal")
self.horiz.append(face)
if isinstance(face.Surface, Part.Plane):
Path.Log.debug("type() == Part.Plane")
if Path.Geom.isVertical(face.Surface.Axis):
Path.Log.debug(" -isVertical()")
# it's a flat horizontal face
self.horiz.append((face, bs))
return True
elif Path.Geom.isHorizontal(face.Surface.Axis):
Path.Log.debug(" flat vertical")
Path.Log.debug(" -isHorizontal()")
self.vert.append(face)
return True
else:
return False
elif isinstance(face.Surface, Part.BSplineSurface):
Path.Log.debug("face Part.BSplineSurface")
if Path.Geom.isRoughly(face.BoundBox.ZLength, 0):
Path.Log.debug(" flat horizontal or almost flat horizontal")
self.horiz.append((face, bs))
return True
elif isinstance(face.Surface, Part.Cylinder) and Path.Geom.isVertical(face.Surface.Axis):
Path.Log.debug("face Part.Cylinder")
Path.Log.debug("type() == Part.Cylinder")
# vertical cylinder wall
if any(e.isClosed() for e in face.Edges):
Path.Log.debug(" isClosed()")
Path.Log.debug(" -e.isClosed()")
# complete cylinder
circle = Part.makeCircle(face.Surface.Radius, face.Surface.Center)
disk = Part.Face(Part.Wire(circle))
disk.translate(FreeCAD.Vector(0, 0, face.BoundBox.ZMin - disk.BoundBox.ZMin))
self.horiz.append(disk)
self.horiz.append((disk, bs))
return True
else:
Path.Log.debug(" not isClosed()")
Path.Log.debug(" -none isClosed()")
# partial cylinder wall
self.vert.append(face)
return True
@@ -251,11 +329,11 @@ class ObjectPocket(PathPocketBase.ObjectPocket):
return True
else:
Path.Log.error("Failed to identify vertical face from {}".format(sub))
return False
else:
Path.Log.debug(" -type(face.Surface): {}".format(type(face.Surface)))
return False
return False
# Eclass
@@ -276,5 +354,3 @@ def Create(name, obj=None, parentJob=None):
obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython", name)
obj.Proxy = ObjectPocket(obj, name, parentJob)
return obj
return obj