From 5124ee33c66bafbd0ac3ca3f0577f3e1cf8bd797 Mon Sep 17 00:00:00 2001 From: sliptonic Date: Tue, 23 Dec 2025 09:12:52 -0600 Subject: [PATCH] CAM: fix tolerance issue with hole detection (#26404) --- src/Mod/CAM/Path/Main/Job.py | 3 ++- src/Mod/CAM/Path/Op/Gui/Base.py | 2 +- src/Mod/CAM/Path/Op/PocketShape.py | 43 +++++++++++++++++++++++++----- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/Mod/CAM/Path/Main/Job.py b/src/Mod/CAM/Path/Main/Job.py index 8e5eb8837e..586e0ee397 100644 --- a/src/Mod/CAM/Path/Main/Job.py +++ b/src/Mod/CAM/Path/Main/Job.py @@ -536,7 +536,8 @@ class ObjectJob: def baseObject(self, obj, base): """Return the base object, not its clone.""" if isResourceClone(obj, base, "Model") or isResourceClone(obj, base, "Base"): - return base.Objects[0] + if hasattr(base, "Objects") and base.Objects: + return base.Objects[0] return base def baseObjects(self, obj): diff --git a/src/Mod/CAM/Path/Op/Gui/Base.py b/src/Mod/CAM/Path/Op/Gui/Base.py index 08c8781fec..a987ffbc8f 100644 --- a/src/Mod/CAM/Path/Op/Gui/Base.py +++ b/src/Mod/CAM/Path/Op/Gui/Base.py @@ -551,7 +551,7 @@ class TaskPanelPage(object): helper function to update obj's Coolant property if a different one has been selected in the combo box.""" option = combo.currentText() - if hasattr(obj, "CoolantMode"): + if hasattr(obj, "CoolantMode") and option: if obj.CoolantMode != option: obj.CoolantMode = option diff --git a/src/Mod/CAM/Path/Op/PocketShape.py b/src/Mod/CAM/Path/Op/PocketShape.py index dc3c50722e..016966047b 100644 --- a/src/Mod/CAM/Path/Op/PocketShape.py +++ b/src/Mod/CAM/Path/Op/PocketShape.py @@ -57,8 +57,8 @@ 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. + def removeHoles(self, solid, face): + """removeHoles(solid, face) ... 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). @@ -67,7 +67,6 @@ class ObjectPocket(PathPocketBase.ObjectPocket): 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 @@ -75,15 +74,24 @@ class ObjectPocket(PathPocketBase.ObjectPocket): outer_wire = face.OuterWire candidate_wires = [w for w in face.Wires if not w.isSame(outer_wire)] + # Adaptive tolerance based on face size + adaptive_tolerance = max(1e-6, min(1e-2, face.BoundBox.DiagonalLength * 1e-5)) + Path.Log.debug( + f"removeHoles: Using adaptive tolerance {adaptive_tolerance} (face diagonal: {face.BoundBox.DiagonalLength})" + ) + + for i, w in enumerate(candidate_wires): + Path.Log.debug(f" Candidate {i}: Length={w.Length}") + if not candidate_wires: return face boss_wires = [] try: - # Create cutting plane from outer wire, offset above face by tolerance + # Create cutting plane from outer wire, offset above face by adaptive_tolerance cutting_plane = Part.Face(outer_wire) - cutting_plane.translate(FreeCAD.Vector(0, 0, tolerance)) + cutting_plane.translate(FreeCAD.Vector(0, 0, adaptive_tolerance)) # Section the solid section = solid.Shape.section(cutting_plane) @@ -93,7 +101,7 @@ class ObjectPocket(PathPocketBase.ObjectPocket): translated_edges = [] for edge in section.Edges: translated_edge = edge.copy() - translated_edge.translate(FreeCAD.Vector(0, 0, -tolerance)) + translated_edge.translate(FreeCAD.Vector(0, 0, -adaptive_tolerance)) translated_edges.append(translated_edge) # Build closed wires from edges @@ -109,16 +117,37 @@ class ObjectPocket(PathPocketBase.ObjectPocket): # ignore any wires that can't be built pass + Path.Log.debug(f"removeHoles: Section found {len(all_section_wires)} wires") + for i, w in enumerate(all_section_wires): + Path.Log.debug(f" Section wire {i}: Length={w.Length}") + # 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: + if length_diff > adaptive_tolerance: boss_wires.append(wire) + Path.Log.debug( + f" Preserving boss wire: Length={wire.Length}, diff={length_diff}" + ) + else: + Path.Log.debug( + f" Discarding wire (too similar to outer): Length={wire.Length}, diff={length_diff}" + ) except Exception as e: Path.Log.error("removeHoles: Section algorithm failed: {}".format(e)) boss_wires = candidate_wires + Path.Log.debug("removeHoles: Section failed, preserving all candidate wires as bosses") + + Path.Log.debug(f"removeHoles: Preserving {len(boss_wires)} boss wires") + for i, w in enumerate(boss_wires): + Path.Log.debug(f" Preserved boss {i}: Length={w.Length}") + + removed_wires = [w for w in candidate_wires if not any(w.isSame(bw) for bw in boss_wires)] + Path.Log.debug(f"removeHoles: Removing {len(removed_wires)} hole wires") + for i, w in enumerate(removed_wires): + Path.Log.debug(f" Removed hole {i}: Length={w.Length}") # Construct new face with outer wire and boss wires wire_compound = Part.makeCompound([outer_wire] + boss_wires)