From 07384466266324f27fc6964da264ec7341f8394b Mon Sep 17 00:00:00 2001 From: PhaseLoop Date: Sat, 22 Mar 2025 01:11:25 +0100 Subject: [PATCH 1/7] Improve VCarve edge routing speed --- src/Mod/CAM/CAMTests/TestPathVcarve.py | 94 ++++++++++ src/Mod/CAM/Path/Op/Vcarve.py | 240 ++++++++++++++++++++----- 2 files changed, 289 insertions(+), 45 deletions(-) diff --git a/src/Mod/CAM/CAMTests/TestPathVcarve.py b/src/Mod/CAM/CAMTests/TestPathVcarve.py index ef7a10e4ac..457d4bb58d 100644 --- a/src/Mod/CAM/CAMTests/TestPathVcarve.py +++ b/src/Mod/CAM/CAMTests/TestPathVcarve.py @@ -23,6 +23,7 @@ import FreeCAD import Part +import Path import Path.Main.Job as PathJob import Path.Op.Vcarve as PathVcarve import math @@ -182,3 +183,96 @@ class TestPathVcarve(PathTestWithAssets): self.assertRoughly(geom.stop, -4) self.assertRoughly(geom.scale, 1) self.assertRoughly(geom.maximumDepth, -4) + + def test17(self): + """Verify if canSkipRepositioning allows to skip if new point is < 0.5 mm""" + + positionHistory = [ + FreeCAD.Base.Vector(0, 0, 0), # previous position + FreeCAD.Base.Vector(0, 1, 5), # current position + ] + + newPosition = FreeCAD.Base.Vector(0, 1.4, 3) + assert PathVcarve.canSkipRepositioning(positionHistory, newPosition, 0.01) + + newPosition = FreeCAD.Base.Vector(0, 1.7, 3) + assert not PathVcarve.canSkipRepositioning(positionHistory, newPosition, 0.01) + + + def test18(self): + """Verify if canSkipRepositioning allows to skip if new edge ends in current position""" + + defaultTolerance = Path.Preferences.defaultGeometryTolerance() + + positionHistory = [ + FreeCAD.Base.Vector(0, 0, 0), # previous position + FreeCAD.Base.Vector(0, 1, 5), # current position + ] + + # new position is same as previous position so we can G1 from (0,1,5) to (0,0,0) because + # we already travelled this path before and it's carved - no need to raise toolbit + newPosition = FreeCAD.Base.Vector(0, 0, 0) + assert PathVcarve.canSkipRepositioning(positionHistory, newPosition, defaultTolerance) + + + # same but should fail because we are out of tolerance + newPosition = FreeCAD.Base.Vector(0, 0.1, 0) + assert not PathVcarve.canSkipRepositioning(positionHistory, newPosition, defaultTolerance) + + # same but is OK because we are within tolerance + newPosition = FreeCAD.Base.Vector(0, 0.1, 0) + assert PathVcarve.canSkipRepositioning(positionHistory, newPosition, 0.1) + + def test19(self): + """Verify virtualBackTrackEdges() various scenarios + """ + + defaultTolerance = Path.Preferences.defaultGeometryTolerance() + + # test scenario 1 - refer to function comments for explanation + + positionHistory = [ + FreeCAD.Base.Vector(0, 0, 0), # previous position + FreeCAD.Base.Vector(0, 1, 5), # current position + ] + + # new edge ends at current position + newEdge = Part.Edge(Part.LineSegment(FreeCAD.Base.Vector(1,2,3), FreeCAD.Base.Vector(0,1,5))) + + virtualEdges = PathVcarve.generateVirtualBackTrackEdges(positionHistory, newEdge, defaultTolerance) + + assert len(virtualEdges) == 1 + + virtualEdge = virtualEdges[0] + # virtualEdge is essentially a reversed newEdge + assert virtualEdge.valueAt(virtualEdge.FirstParameter) == newEdge.valueAt(newEdge.LastParameter) + assert virtualEdge.valueAt(virtualEdge.LastParameter) == newEdge.valueAt(newEdge.FirstParameter) + + + # test scenario 2 - refer to function comments for explanation + + positionHistory = [ + FreeCAD.Base.Vector(0, 0, 0), # previous position + FreeCAD.Base.Vector(0, 1, 5), # current position + ] + + # new edge ends at previous position + newEdge = Part.Edge(Part.LineSegment(FreeCAD.Base.Vector(1,2,3), FreeCAD.Base.Vector(0,0,0))) + + virtualEdges = PathVcarve.generateVirtualBackTrackEdges(positionHistory, newEdge, defaultTolerance) + + assert len(virtualEdges) == 2 + + virtualEdge1 = virtualEdges[0] + virtualEdge2 = virtualEdges[1] + + # 2 virtual edges (current position, previous position) and (previous position, new edge start) + + assert virtualEdge1.valueAt(virtualEdge1.FirstParameter) == positionHistory[-1] + assert virtualEdge1.valueAt(virtualEdge1.LastParameter) == positionHistory[-2] + + + assert virtualEdge2.valueAt(virtualEdge2.FirstParameter) == positionHistory[-2] + assert virtualEdge2.valueAt(virtualEdge2.LastParameter) == newEdge.valueAt(newEdge.FirstParameter) + + \ No newline at end of file diff --git a/src/Mod/CAM/Path/Op/Vcarve.py b/src/Mod/CAM/Path/Op/Vcarve.py index 3b09145ef2..d5d2a47a62 100644 --- a/src/Mod/CAM/Path/Op/Vcarve.py +++ b/src/Mod/CAM/Path/Op/Vcarve.py @@ -145,6 +145,113 @@ def _sortVoronoiWires(wires, start=FreeCAD.Vector(0, 0, 0)): return result +def getReversedEdge(edge): + # returns a reversed edge (copy of original edge) + curve = edge.Curve + first = edge.FirstParameter + last = edge.LastParameter + curve_c = curve.copy() + curve_c.reverse() + return Part.Edge( + curve_c, curve_c.reversedParameter(last), curve_c.reversedParameter(first) + ) + + +def generateVirtualBackTrackEdges(positionHistory, nextEdge, tolerance) -> list: + """ + Generate a list of "virtual edges" to backtrack using normal G1 moves instead lifting + toolbit and repositioning using G0 to get to beginning of nextEdge. + Those virtual edges are either already carved or are part of nextEdge anyway so it's safe + to follow them without lifting toolbit. This approach makes carving a lot of faster. + """ + + if not positionHistory: + return [] + + backTrackEdges = [] + + currentPosition = positionHistory[-1] + previousPosition = positionHistory[-2] + + nextEdgeStart = nextEdge.valueAt(nextEdge.FirstParameter) + nextEdgeEnd = nextEdge.valueAt(nextEdge.LastParameter) + + # Scenario 1 + # + # in some cases travelling between wires looks like that: + # A ========= B ------- D + # | + # C + # + # we follow first wire from A to B - new wire starts at C and goes through B -> D + # Repositioning to position C using G0 command does not make sense and it's slow + # We can insert "virtual" edge B->C at the beginning of a second wire to make + # continous CNC head movement + # + + if nextEdgeEnd.isEqual(currentPosition, tolerance): + # virtual edge is "reversed" + virtualEdge = Part.Edge(Part.LineSegment(nextEdgeEnd, nextEdgeStart)) + backTrackEdges.append(virtualEdge) + + # Scenario 2 + # next edge has common node with previous position but it's reversed + # A C + # \ // + # \ // + # B + # We went from B to C and next wire edge starts at A and goes back to B + # Normally we would G0 jump from C to A and start from there, + # but we can go back from C to B and then to A (by adding extra edge which + # is reversed A->B edge). + + elif nextEdgeEnd.isEqual(previousPosition, tolerance): + # travel back to the previous toolbit position + virtualEdge = Part.Edge(Part.LineSegment(currentPosition, previousPosition)) + backTrackEdges.append(virtualEdge) + # instead of G0 - just carve the edge in reverse direction + backTrackEdges.append(getReversedEdge(nextEdge)) + + + return backTrackEdges + + + +def canSkipRepositioning(positionHistory, newPosition, tolerance): + """ + Calculate if it makes sense to raise head to safe height and reposition before + starting to cut another edge + """ + + if not positionHistory: + return False + + currentPosition = positionHistory[-1] + previousPosition = positionHistory[-2] + + # get vertex position on X/Y plane only + v0 = FreeCAD.Base.Vector(currentPosition.x, currentPosition.y) + v1 = FreeCAD.Base.Vector(newPosition.x, newPosition.y) + + # do not bother with G0 if new and current position differ by less than 0.5 mm in X/Y + if v0.distanceToPoint(v1) <= 0.5: + return True + + # if new position is same as previous head position we can essentially + # go back traversing same edge. This is handy with short "detour" edges like that: + # + # A--------------B===============C + # | + # D + # We are travelling wire from A -> B -> D within first wire and ending at D. New wire starts with edge going from + # B to C. We don't need to G0 to point B, we can skip positioning because if we travel G1 move from D to B we will follow already + # carved path + + if newPosition.isEqual(previousPosition, tolerance): + return True + + return False + class _Geometry(object): """POD class so the limits only have to be calculated once.""" @@ -352,6 +459,8 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): obj.Colinear = 10.0 obj.Discretize = 0.25 obj.Tolerance = Path.Preferences.defaultGeometryTolerance() + # keep copy in local object to use in methods which do not operate directly on obj + self.Tolerance = obj.Tolerance self.setupAdditionalProperties(obj) def opOnDocumentRestored(self, obj): @@ -363,8 +472,11 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): constructs a medial axis path using openvoronoi :returns: dictionary - each face object is a key containing list of wires""" - wires_by_face = dict() - self.voronoiDebugCache = dict() + medial_wires_by_face = dict() + edges_by_face = dict() # non processed voronoi edges, for debugging + + self.voronoiDebugMedialCache = dict() + self.voronoiDebugEdgeCache = dict() def is_exterior(vertex, face): vector = FreeCAD.Vector(vertex.toPoint(face.BoundBox.ZMin)) @@ -403,6 +515,7 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): insert_many_wires(vd, f.Wires) vd.construct() + edges_by_face[f] = vd.Edges for e in vd.Edges: if e.isPrimary(): @@ -429,10 +542,12 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): wires = _sortVoronoiWires(wires) voronoiWires.extend(wires) - wires_by_face[f] = voronoiWires - self.voronoiDebugCache = wires_by_face + medial_wires_by_face[f] = voronoiWires - return wires_by_face + self.voronoiDebugMedialCache = medial_wires_by_face + self.voronoiDebugEdgeCache = edges_by_face + + return medial_wires_by_face def buildCommandList(self, obj, faces): """ @@ -440,52 +555,55 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): wire list from buildMedialWires """ - def getCurrentPosition(wire): + def getPositionHistory(wire): """ - Calculate CNC head position assuming it reached the end of the wire + Get CNC current and previous head position assuming it reached the end of the wire + returns: previousPosition, currentPostion tuple """ if not wire: return None lastEdge = wire[-1] - return lastEdge.valueAt(lastEdge.LastParameter) + return ( + lastEdge.valueAt(lastEdge.FirstParameter), + lastEdge.valueAt(lastEdge.LastParameter), + ) def cutWires(wires, pathlist, optimizeMovements=False): - currentPosition = None + + positionHistory = None + for w in wires: pWire = _getPartEdges(obj, w, geom) if pWire: - pathlist.extend(_cutWire(pWire, currentPosition)) + pathlist.extend(_cutWire(pWire, positionHistory)) - # movement optimization only works if we provide current head position + # movement optimization only works if we provide head position history if optimizeMovements: - currentPosition = getCurrentPosition(pWire) + positionHistory = getPositionHistory(pWire) - def canSkipRepositioning(currentPosition, newPosition): - """ - Calculate if it makes sense to raise head to safe height and reposition before - starting to cut another edge - """ - - if not currentPosition: - return False - - # get vertex position on X/Y plane only - v0 = FreeCAD.Base.Vector(currentPosition.x, currentPosition.y) - v1 = FreeCAD.Base.Vector(newPosition.x, newPosition.y) - - return v0.distanceToPoint(v1) <= 0.5 - - def _cutWire(wire, currentPosition=None): + def _cutWire(wire, positionHistory=None): path = [] - e = wire[0] + backtrack_edges = [] + + # we start vcarving another wire which may not be connected to previous one + # but using some routing logic we may avoid raising CNC toolbit and using G0 + # and instead traverse back already carved edges at full speed + + + + edge_list = backtrack_edges + wire + + e = edge_list[0] newPosition = e.valueAt(e.FirstParameter) - # raise and reposition the head only if new wire starts further than 0.5 mm - # from current head position - if not canSkipRepositioning(currentPosition, newPosition): + hSpeed = obj.ToolController.HorizFeed.Value + vSpeed = obj.ToolController.VertFeed.Value + + # check if we can smart-skip using G0 repositioning which is slow + if not canSkipRepositioning(positionHistory, newPosition, obj.Tolerance): path.append(Path.Command("G0", {"Z": obj.SafeHeight.Value})) path.append( Path.Command( @@ -493,14 +611,25 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): ) ) - hSpeed = obj.ToolController.HorizFeed.Value - vSpeed = obj.ToolController.VertFeed.Value - path.append( - Path.Command( - "G1", {"X": newPosition.x, "Y": newPosition.y, "Z": newPosition.z, "F": vSpeed} + path.append( + Path.Command( + "G1", + {"X": newPosition.x, "Y": newPosition.y, "Z": newPosition.z, "F": vSpeed}, + ) ) - ) - for e in wire: + else: # skip repositioning + # technically hSpeed + vSpeed should be properly recalculated into F parameter + # as cmdsForEdge does but we either cut max 0.5 mm through stock or backtrack + # over already carved edges, so hSpeed will be just fine + path.append( + Path.Command( + "G1 X{} Y{} Z{} F{}".format( + newPosition.x, newPosition.y, newPosition.z, hSpeed + ) + ) + ) + + for e in edge_list: path.extend(Path.Geom.cmdsForEdge(e, hSpeed=hSpeed, vSpeed=vSpeed)) return path @@ -545,7 +674,8 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): """opExecute(obj) ... process engraving operation""" Path.Log.track() - self.voronoiDebugCache = None + self.voronoiDebugMedialCache = None + self.voronoiDebugEdgesCache = None if obj.ToolController is None: return @@ -625,18 +755,18 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): and hasattr(tool, "TipDiameter") ) - def debugVoronoi(self, obj): - """Debug function to display calculated voronoi edges""" + def debugVoronoiMedial(self, obj): + """Debug function to display calculated voronoi medial wires""" - if not getattr(self, "voronoiDebugCache", None): + if not getattr(self, "voronoiDebugMedialCache", None): Path.Log.error("debugVoronoi: empty debug cache. Recompute VCarve operation first") return - vPart = FreeCAD.activeDocument().addObject("App::Part", f"{obj.Name}-VoronoiDebug") + vPart = FreeCAD.activeDocument().addObject("App::Part", f"{obj.Name}-VoronoiDebugMedial") wiresToShow = [] - for face, wires in self.voronoiDebugCache.items(): + for face, wires in self.voronoiDebugMedialCache.items(): for wire in wires: currentPartWire = Part.Wire() currentPartWire.fixTolerance(0.01) @@ -652,6 +782,26 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): for w in wiresToShow: vPart.addObject(Part.show(w)) + def debugVoronoiEdges(self, obj): + """Debug function to display calculated voronoi edges""" + + if not getattr(self, "voronoiDebugEdgeCache", None): + Path.Log.error("debugVoronoi: empty debug cache. Recompute VCarve operation first") + return + + vPart = FreeCAD.activeDocument().addObject("App::Part", f"{obj.Name}-VoronoiDebugEdge") + + edgesToShow = [] + + for face, edges in self.voronoiDebugEdgeCache.items(): + for edge in edges: # those are voronoi Edge objects, not FC Edge + currentEdge = edge.toShape() + + edgesToShow.append(currentEdge) + + for e in edgesToShow: + vPart.addObject(Part.show(e)) + def SetupProperties(): return ["Discretize"] From cdf0417c65c8e6472584b71dd1d7a7b030488d67 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 16:21:08 +0000 Subject: [PATCH 2/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/Mod/CAM/CAMTests/TestPathVcarve.py | 41 +++++++++++++++----------- src/Mod/CAM/Path/Op/Vcarve.py | 13 +++----- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/Mod/CAM/CAMTests/TestPathVcarve.py b/src/Mod/CAM/CAMTests/TestPathVcarve.py index 457d4bb58d..471971a220 100644 --- a/src/Mod/CAM/CAMTests/TestPathVcarve.py +++ b/src/Mod/CAM/CAMTests/TestPathVcarve.py @@ -198,7 +198,6 @@ class TestPathVcarve(PathTestWithAssets): newPosition = FreeCAD.Base.Vector(0, 1.7, 3) assert not PathVcarve.canSkipRepositioning(positionHistory, newPosition, 0.01) - def test18(self): """Verify if canSkipRepositioning allows to skip if new edge ends in current position""" @@ -214,7 +213,6 @@ class TestPathVcarve(PathTestWithAssets): newPosition = FreeCAD.Base.Vector(0, 0, 0) assert PathVcarve.canSkipRepositioning(positionHistory, newPosition, defaultTolerance) - # same but should fail because we are out of tolerance newPosition = FreeCAD.Base.Vector(0, 0.1, 0) assert not PathVcarve.canSkipRepositioning(positionHistory, newPosition, defaultTolerance) @@ -224,8 +222,7 @@ class TestPathVcarve(PathTestWithAssets): assert PathVcarve.canSkipRepositioning(positionHistory, newPosition, 0.1) def test19(self): - """Verify virtualBackTrackEdges() various scenarios - """ + """Verify virtualBackTrackEdges() various scenarios""" defaultTolerance = Path.Preferences.defaultGeometryTolerance() @@ -237,17 +234,24 @@ class TestPathVcarve(PathTestWithAssets): ] # new edge ends at current position - newEdge = Part.Edge(Part.LineSegment(FreeCAD.Base.Vector(1,2,3), FreeCAD.Base.Vector(0,1,5))) + newEdge = Part.Edge( + Part.LineSegment(FreeCAD.Base.Vector(1, 2, 3), FreeCAD.Base.Vector(0, 1, 5)) + ) - virtualEdges = PathVcarve.generateVirtualBackTrackEdges(positionHistory, newEdge, defaultTolerance) + virtualEdges = PathVcarve.generateVirtualBackTrackEdges( + positionHistory, newEdge, defaultTolerance + ) assert len(virtualEdges) == 1 - + virtualEdge = virtualEdges[0] # virtualEdge is essentially a reversed newEdge - assert virtualEdge.valueAt(virtualEdge.FirstParameter) == newEdge.valueAt(newEdge.LastParameter) - assert virtualEdge.valueAt(virtualEdge.LastParameter) == newEdge.valueAt(newEdge.FirstParameter) - + assert virtualEdge.valueAt(virtualEdge.FirstParameter) == newEdge.valueAt( + newEdge.LastParameter + ) + assert virtualEdge.valueAt(virtualEdge.LastParameter) == newEdge.valueAt( + newEdge.FirstParameter + ) # test scenario 2 - refer to function comments for explanation @@ -257,12 +261,16 @@ class TestPathVcarve(PathTestWithAssets): ] # new edge ends at previous position - newEdge = Part.Edge(Part.LineSegment(FreeCAD.Base.Vector(1,2,3), FreeCAD.Base.Vector(0,0,0))) + newEdge = Part.Edge( + Part.LineSegment(FreeCAD.Base.Vector(1, 2, 3), FreeCAD.Base.Vector(0, 0, 0)) + ) - virtualEdges = PathVcarve.generateVirtualBackTrackEdges(positionHistory, newEdge, defaultTolerance) + virtualEdges = PathVcarve.generateVirtualBackTrackEdges( + positionHistory, newEdge, defaultTolerance + ) assert len(virtualEdges) == 2 - + virtualEdge1 = virtualEdges[0] virtualEdge2 = virtualEdges[1] @@ -271,8 +279,7 @@ class TestPathVcarve(PathTestWithAssets): assert virtualEdge1.valueAt(virtualEdge1.FirstParameter) == positionHistory[-1] assert virtualEdge1.valueAt(virtualEdge1.LastParameter) == positionHistory[-2] - assert virtualEdge2.valueAt(virtualEdge2.FirstParameter) == positionHistory[-2] - assert virtualEdge2.valueAt(virtualEdge2.LastParameter) == newEdge.valueAt(newEdge.FirstParameter) - - \ No newline at end of file + assert virtualEdge2.valueAt(virtualEdge2.LastParameter) == newEdge.valueAt( + newEdge.FirstParameter + ) diff --git a/src/Mod/CAM/Path/Op/Vcarve.py b/src/Mod/CAM/Path/Op/Vcarve.py index d5d2a47a62..f94ef33d9a 100644 --- a/src/Mod/CAM/Path/Op/Vcarve.py +++ b/src/Mod/CAM/Path/Op/Vcarve.py @@ -145,6 +145,7 @@ def _sortVoronoiWires(wires, start=FreeCAD.Vector(0, 0, 0)): return result + def getReversedEdge(edge): # returns a reversed edge (copy of original edge) curve = edge.Curve @@ -152,9 +153,7 @@ def getReversedEdge(edge): last = edge.LastParameter curve_c = curve.copy() curve_c.reverse() - return Part.Edge( - curve_c, curve_c.reversedParameter(last), curve_c.reversedParameter(first) - ) + return Part.Edge(curve_c, curve_c.reversedParameter(last), curve_c.reversedParameter(first)) def generateVirtualBackTrackEdges(positionHistory, nextEdge, tolerance) -> list: @@ -167,7 +166,7 @@ def generateVirtualBackTrackEdges(positionHistory, nextEdge, tolerance) -> list: if not positionHistory: return [] - + backTrackEdges = [] currentPosition = positionHistory[-1] @@ -177,7 +176,7 @@ def generateVirtualBackTrackEdges(positionHistory, nextEdge, tolerance) -> list: nextEdgeEnd = nextEdge.valueAt(nextEdge.LastParameter) # Scenario 1 - # + # # in some cases travelling between wires looks like that: # A ========= B ------- D # | @@ -212,11 +211,9 @@ def generateVirtualBackTrackEdges(positionHistory, nextEdge, tolerance) -> list: # instead of G0 - just carve the edge in reverse direction backTrackEdges.append(getReversedEdge(nextEdge)) - return backTrackEdges - def canSkipRepositioning(positionHistory, newPosition, tolerance): """ Calculate if it makes sense to raise head to safe height and reposition before @@ -592,8 +589,6 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): # but using some routing logic we may avoid raising CNC toolbit and using G0 # and instead traverse back already carved edges at full speed - - edge_list = backtrack_edges + wire e = edge_list[0] From 802b6987526f1730b0c41ed6caaa423589de9590 Mon Sep 17 00:00:00 2001 From: phaseloop Date: Wed, 5 Nov 2025 11:21:34 +0000 Subject: [PATCH 3/7] fix linting issues --- src/Mod/CAM/CAMTests/TestPathVcarve.py | 61 +++++++++++++++-------- src/Mod/CAM/Path/Op/Vcarve.py | 67 ++++++++++++++++++-------- 2 files changed, 86 insertions(+), 42 deletions(-) diff --git a/src/Mod/CAM/CAMTests/TestPathVcarve.py b/src/Mod/CAM/CAMTests/TestPathVcarve.py index 457d4bb58d..aedce419eb 100644 --- a/src/Mod/CAM/CAMTests/TestPathVcarve.py +++ b/src/Mod/CAM/CAMTests/TestPathVcarve.py @@ -1,5 +1,9 @@ # SPDX-License-Identifier: LGPL-2.1-or-later +""" +Testing functions for VCarve operation module +""" + # *************************************************************************** # * Copyright (c) 2020 sliptonic * # * * @@ -29,8 +33,10 @@ import Path.Op.Vcarve as PathVcarve import math from CAMTests.PathTestUtils import PathTestWithAssets +# pylint: disable=too-few-public-methods, protected-access -class VbitTool(object): + +class VbitTool: """Faked out vcarve tool""" def __init__(self, dia, angle, tipDia): @@ -52,6 +58,7 @@ class TestPathVcarve(PathTestWithAssets): FreeCAD.closeDocument(self.doc.Name) def testFinishingPass(self): + """Check if enabling finishing pass adds another path with required z-depth""" self.doc = FreeCAD.newDocument() part1 = FreeCAD.ActiveDocument.addObject("Part::Feature", "TestShape") part2 = FreeCAD.ActiveDocument.addObject("Part::Feature", "TestShape") @@ -198,7 +205,6 @@ class TestPathVcarve(PathTestWithAssets): newPosition = FreeCAD.Base.Vector(0, 1.7, 3) assert not PathVcarve.canSkipRepositioning(positionHistory, newPosition, 0.01) - def test18(self): """Verify if canSkipRepositioning allows to skip if new edge ends in current position""" @@ -212,20 +218,22 @@ class TestPathVcarve(PathTestWithAssets): # new position is same as previous position so we can G1 from (0,1,5) to (0,0,0) because # we already travelled this path before and it's carved - no need to raise toolbit newPosition = FreeCAD.Base.Vector(0, 0, 0) - assert PathVcarve.canSkipRepositioning(positionHistory, newPosition, defaultTolerance) - + assert PathVcarve.canSkipRepositioning( + positionHistory, newPosition, defaultTolerance + ) # same but should fail because we are out of tolerance newPosition = FreeCAD.Base.Vector(0, 0.1, 0) - assert not PathVcarve.canSkipRepositioning(positionHistory, newPosition, defaultTolerance) + assert not PathVcarve.canSkipRepositioning( + positionHistory, newPosition, defaultTolerance + ) # same but is OK because we are within tolerance newPosition = FreeCAD.Base.Vector(0, 0.1, 0) assert PathVcarve.canSkipRepositioning(positionHistory, newPosition, 0.1) def test19(self): - """Verify virtualBackTrackEdges() various scenarios - """ + """Verify virtualBackTrackEdges() various scenarios""" defaultTolerance = Path.Preferences.defaultGeometryTolerance() @@ -237,17 +245,24 @@ class TestPathVcarve(PathTestWithAssets): ] # new edge ends at current position - newEdge = Part.Edge(Part.LineSegment(FreeCAD.Base.Vector(1,2,3), FreeCAD.Base.Vector(0,1,5))) + newEdge = Part.Edge( + Part.LineSegment(FreeCAD.Base.Vector(1, 2, 3), FreeCAD.Base.Vector(0, 1, 5)) + ) - virtualEdges = PathVcarve.generateVirtualBackTrackEdges(positionHistory, newEdge, defaultTolerance) + virtualEdges = PathVcarve.generateVirtualBackTrackEdges( + positionHistory, newEdge, defaultTolerance + ) assert len(virtualEdges) == 1 - + virtualEdge = virtualEdges[0] # virtualEdge is essentially a reversed newEdge - assert virtualEdge.valueAt(virtualEdge.FirstParameter) == newEdge.valueAt(newEdge.LastParameter) - assert virtualEdge.valueAt(virtualEdge.LastParameter) == newEdge.valueAt(newEdge.FirstParameter) - + assert virtualEdge.valueAt(virtualEdge.FirstParameter) == newEdge.valueAt( + newEdge.LastParameter + ) + assert virtualEdge.valueAt(virtualEdge.LastParameter) == newEdge.valueAt( + newEdge.FirstParameter + ) # test scenario 2 - refer to function comments for explanation @@ -257,22 +272,26 @@ class TestPathVcarve(PathTestWithAssets): ] # new edge ends at previous position - newEdge = Part.Edge(Part.LineSegment(FreeCAD.Base.Vector(1,2,3), FreeCAD.Base.Vector(0,0,0))) + newEdge = Part.Edge( + Part.LineSegment(FreeCAD.Base.Vector(1, 2, 3), FreeCAD.Base.Vector(0, 0, 0)) + ) - virtualEdges = PathVcarve.generateVirtualBackTrackEdges(positionHistory, newEdge, defaultTolerance) + virtualEdges = PathVcarve.generateVirtualBackTrackEdges( + positionHistory, newEdge, defaultTolerance + ) assert len(virtualEdges) == 2 - + virtualEdge1 = virtualEdges[0] virtualEdge2 = virtualEdges[1] - # 2 virtual edges (current position, previous position) and (previous position, new edge start) + # 2 virtual edges (current position, previous position) and + # (previous position, new edge start) assert virtualEdge1.valueAt(virtualEdge1.FirstParameter) == positionHistory[-1] assert virtualEdge1.valueAt(virtualEdge1.LastParameter) == positionHistory[-2] - assert virtualEdge2.valueAt(virtualEdge2.FirstParameter) == positionHistory[-2] - assert virtualEdge2.valueAt(virtualEdge2.LastParameter) == newEdge.valueAt(newEdge.FirstParameter) - - \ No newline at end of file + assert virtualEdge2.valueAt(virtualEdge2.LastParameter) == newEdge.valueAt( + newEdge.FirstParameter + ) diff --git a/src/Mod/CAM/Path/Op/Vcarve.py b/src/Mod/CAM/Path/Op/Vcarve.py index d5d2a47a62..d0104a6a9f 100644 --- a/src/Mod/CAM/Path/Op/Vcarve.py +++ b/src/Mod/CAM/Path/Op/Vcarve.py @@ -145,6 +145,7 @@ def _sortVoronoiWires(wires, start=FreeCAD.Vector(0, 0, 0)): return result + def getReversedEdge(edge): # returns a reversed edge (copy of original edge) curve = edge.Curve @@ -167,7 +168,7 @@ def generateVirtualBackTrackEdges(positionHistory, nextEdge, tolerance) -> list: if not positionHistory: return [] - + backTrackEdges = [] currentPosition = positionHistory[-1] @@ -177,7 +178,7 @@ def generateVirtualBackTrackEdges(positionHistory, nextEdge, tolerance) -> list: nextEdgeEnd = nextEdge.valueAt(nextEdge.LastParameter) # Scenario 1 - # + # # in some cases travelling between wires looks like that: # A ========= B ------- D # | @@ -186,7 +187,7 @@ def generateVirtualBackTrackEdges(positionHistory, nextEdge, tolerance) -> list: # we follow first wire from A to B - new wire starts at C and goes through B -> D # Repositioning to position C using G0 command does not make sense and it's slow # We can insert "virtual" edge B->C at the beginning of a second wire to make - # continous CNC head movement + # continuous CNC head movement # if nextEdgeEnd.isEqual(currentPosition, tolerance): @@ -212,11 +213,9 @@ def generateVirtualBackTrackEdges(positionHistory, nextEdge, tolerance) -> list: # instead of G0 - just carve the edge in reverse direction backTrackEdges.append(getReversedEdge(nextEdge)) - return backTrackEdges - def canSkipRepositioning(positionHistory, newPosition, tolerance): """ Calculate if it makes sense to raise head to safe height and reposition before @@ -398,7 +397,9 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): "App::PropertyLinkList", "BaseShapes", "Path", - QT_TRANSLATE_NOOP("App::Property", "Additional base objects to be engraved"), + QT_TRANSLATE_NOOP( + "App::Property", "Additional base objects to be engraved" + ), ) obj.setEditorMode("BaseShapes", 2) # hide @@ -437,7 +438,9 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): "App::PropertyFloat", "Discretize", "Path", - QT_TRANSLATE_NOOP("App::Property", "The deflection value for discretizing arcs"), + QT_TRANSLATE_NOOP( + "App::Property", "The deflection value for discretizing arcs" + ), ) obj.addProperty( "App::PropertyFloat", @@ -501,7 +504,9 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): dist = ptv[-1].distanceToPoint(ptv[0]) if dist < FreeCAD.Base.Precision.confusion(): Path.Log.debug( - "Removing bad carve point: {} from polygon origin".format(dist) + "Removing bad carve point: {} from polygon origin".format( + dist + ) ) del ptv[-1] ptv.append(ptv[0]) @@ -592,8 +597,6 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): # but using some routing logic we may avoid raising CNC toolbit and using G0 # and instead traverse back already carved edges at full speed - - edge_list = backtrack_edges + wire e = edge_list[0] @@ -607,14 +610,24 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): path.append(Path.Command("G0", {"Z": obj.SafeHeight.Value})) path.append( Path.Command( - "G0", {"X": newPosition.x, "Y": newPosition.y, "Z": obj.SafeHeight.Value} + "G0", + { + "X": newPosition.x, + "Y": newPosition.y, + "Z": obj.SafeHeight.Value, + }, ) ) path.append( Path.Command( "G1", - {"X": newPosition.x, "Y": newPosition.y, "Z": newPosition.z, "F": vSpeed}, + { + "X": newPosition.x, + "Y": newPosition.y, + "Z": newPosition.z, + "F": vSpeed, + }, ) ) else: # skip repositioning @@ -652,7 +665,9 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): _maximumUsableDepth = _get_maximumUsableDepth(wires, geom) if _maximumUsableDepth is not None: maximumUsableDepth = _maximumUsableDepth - Path.Log.debug(f"Maximum usable depth for current face: {maximumUsableDepth}") + Path.Log.debug( + f"Maximum usable depth for current face: {maximumUsableDepth}" + ) # first pass cutWires(wires, pathlist, obj.OptimizeMovements) @@ -691,7 +706,9 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): if obj.ToolController.Tool.CuttingEdgeAngle >= 180.0: Path.Log.info( - translate("CAM_Vcarve", "Engraver cutting edge angle must be < 180 degrees.") + translate( + "CAM_Vcarve", "Engraver cutting edge angle must be < 180 degrees." + ) ) return @@ -709,9 +726,9 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): if not faces: for model in self.model: - if model.isDerivedFrom("Sketcher::SketchObject") or model.isDerivedFrom( - "Part::Part2DObject" - ): + if model.isDerivedFrom( + "Sketcher::SketchObject" + ) or model.isDerivedFrom("Part::Part2DObject"): faces.extend(model.Shape.Faces) if faces: @@ -759,10 +776,14 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): """Debug function to display calculated voronoi medial wires""" if not getattr(self, "voronoiDebugMedialCache", None): - Path.Log.error("debugVoronoi: empty debug cache. Recompute VCarve operation first") + Path.Log.error( + "debugVoronoi: empty debug cache. Recompute VCarve operation first" + ) return - vPart = FreeCAD.activeDocument().addObject("App::Part", f"{obj.Name}-VoronoiDebugMedial") + vPart = FreeCAD.activeDocument().addObject( + "App::Part", f"{obj.Name}-VoronoiDebugMedial" + ) wiresToShow = [] @@ -786,10 +807,14 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): """Debug function to display calculated voronoi edges""" if not getattr(self, "voronoiDebugEdgeCache", None): - Path.Log.error("debugVoronoi: empty debug cache. Recompute VCarve operation first") + Path.Log.error( + "debugVoronoi: empty debug cache. Recompute VCarve operation first" + ) return - vPart = FreeCAD.activeDocument().addObject("App::Part", f"{obj.Name}-VoronoiDebugEdge") + vPart = FreeCAD.activeDocument().addObject( + "App::Part", f"{obj.Name}-VoronoiDebugEdge" + ) edgesToShow = [] From c9a5710f820d6f1e9e7de9f087e206f78193eccd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 11:24:35 +0000 Subject: [PATCH 4/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/Mod/CAM/CAMTests/TestPathVcarve.py | 8 ++--- src/Mod/CAM/Path/Op/Vcarve.py | 42 ++++++++------------------ 2 files changed, 14 insertions(+), 36 deletions(-) diff --git a/src/Mod/CAM/CAMTests/TestPathVcarve.py b/src/Mod/CAM/CAMTests/TestPathVcarve.py index aedce419eb..aeb523fac7 100644 --- a/src/Mod/CAM/CAMTests/TestPathVcarve.py +++ b/src/Mod/CAM/CAMTests/TestPathVcarve.py @@ -218,15 +218,11 @@ class TestPathVcarve(PathTestWithAssets): # new position is same as previous position so we can G1 from (0,1,5) to (0,0,0) because # we already travelled this path before and it's carved - no need to raise toolbit newPosition = FreeCAD.Base.Vector(0, 0, 0) - assert PathVcarve.canSkipRepositioning( - positionHistory, newPosition, defaultTolerance - ) + assert PathVcarve.canSkipRepositioning(positionHistory, newPosition, defaultTolerance) # same but should fail because we are out of tolerance newPosition = FreeCAD.Base.Vector(0, 0.1, 0) - assert not PathVcarve.canSkipRepositioning( - positionHistory, newPosition, defaultTolerance - ) + assert not PathVcarve.canSkipRepositioning(positionHistory, newPosition, defaultTolerance) # same but is OK because we are within tolerance newPosition = FreeCAD.Base.Vector(0, 0.1, 0) diff --git a/src/Mod/CAM/Path/Op/Vcarve.py b/src/Mod/CAM/Path/Op/Vcarve.py index ee049ec397..092eefa347 100644 --- a/src/Mod/CAM/Path/Op/Vcarve.py +++ b/src/Mod/CAM/Path/Op/Vcarve.py @@ -395,9 +395,7 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): "App::PropertyLinkList", "BaseShapes", "Path", - QT_TRANSLATE_NOOP( - "App::Property", "Additional base objects to be engraved" - ), + QT_TRANSLATE_NOOP("App::Property", "Additional base objects to be engraved"), ) obj.setEditorMode("BaseShapes", 2) # hide @@ -436,9 +434,7 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): "App::PropertyFloat", "Discretize", "Path", - QT_TRANSLATE_NOOP( - "App::Property", "The deflection value for discretizing arcs" - ), + QT_TRANSLATE_NOOP("App::Property", "The deflection value for discretizing arcs"), ) obj.addProperty( "App::PropertyFloat", @@ -502,9 +498,7 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): dist = ptv[-1].distanceToPoint(ptv[0]) if dist < FreeCAD.Base.Precision.confusion(): Path.Log.debug( - "Removing bad carve point: {} from polygon origin".format( - dist - ) + "Removing bad carve point: {} from polygon origin".format(dist) ) del ptv[-1] ptv.append(ptv[0]) @@ -663,9 +657,7 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): _maximumUsableDepth = _get_maximumUsableDepth(wires, geom) if _maximumUsableDepth is not None: maximumUsableDepth = _maximumUsableDepth - Path.Log.debug( - f"Maximum usable depth for current face: {maximumUsableDepth}" - ) + Path.Log.debug(f"Maximum usable depth for current face: {maximumUsableDepth}") # first pass cutWires(wires, pathlist, obj.OptimizeMovements) @@ -704,9 +696,7 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): if obj.ToolController.Tool.CuttingEdgeAngle >= 180.0: Path.Log.info( - translate( - "CAM_Vcarve", "Engraver cutting edge angle must be < 180 degrees." - ) + translate("CAM_Vcarve", "Engraver cutting edge angle must be < 180 degrees.") ) return @@ -724,9 +714,9 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): if not faces: for model in self.model: - if model.isDerivedFrom( - "Sketcher::SketchObject" - ) or model.isDerivedFrom("Part::Part2DObject"): + if model.isDerivedFrom("Sketcher::SketchObject") or model.isDerivedFrom( + "Part::Part2DObject" + ): faces.extend(model.Shape.Faces) if faces: @@ -774,14 +764,10 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): """Debug function to display calculated voronoi medial wires""" if not getattr(self, "voronoiDebugMedialCache", None): - Path.Log.error( - "debugVoronoi: empty debug cache. Recompute VCarve operation first" - ) + Path.Log.error("debugVoronoi: empty debug cache. Recompute VCarve operation first") return - vPart = FreeCAD.activeDocument().addObject( - "App::Part", f"{obj.Name}-VoronoiDebugMedial" - ) + vPart = FreeCAD.activeDocument().addObject("App::Part", f"{obj.Name}-VoronoiDebugMedial") wiresToShow = [] @@ -805,14 +791,10 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): """Debug function to display calculated voronoi edges""" if not getattr(self, "voronoiDebugEdgeCache", None): - Path.Log.error( - "debugVoronoi: empty debug cache. Recompute VCarve operation first" - ) + Path.Log.error("debugVoronoi: empty debug cache. Recompute VCarve operation first") return - vPart = FreeCAD.activeDocument().addObject( - "App::Part", f"{obj.Name}-VoronoiDebugEdge" - ) + vPart = FreeCAD.activeDocument().addObject("App::Part", f"{obj.Name}-VoronoiDebugEdge") edgesToShow = [] From a7f43c891e58ecf486dd44628ed5c2dfc4387e3a Mon Sep 17 00:00:00 2001 From: Phaseloop Date: Tue, 11 Nov 2025 00:42:14 +0100 Subject: [PATCH 5/7] fix job editor crashing --- src/Mod/CAM/Path/Op/Vcarve.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Mod/CAM/Path/Op/Vcarve.py b/src/Mod/CAM/Path/Op/Vcarve.py index 092eefa347..6d92417ca4 100644 --- a/src/Mod/CAM/Path/Op/Vcarve.py +++ b/src/Mod/CAM/Path/Op/Vcarve.py @@ -457,7 +457,9 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): obj.Discretize = 0.25 obj.Tolerance = Path.Preferences.defaultGeometryTolerance() # keep copy in local object to use in methods which do not operate directly on obj - self.Tolerance = obj.Tolerance + # we use getattr because OpsDefaultEditor may trigger this method to gather list of + # default operation settings but reading from OpPrototype object fails + self.Tolerance = getattr(obj, "Tolerance", Path.Preferences.defaultGeometryTolerance()) self.setupAdditionalProperties(obj) def opOnDocumentRestored(self, obj): From e6cbf6f800b05d18bf8157164c56cf1c83216c99 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 23:43:45 +0000 Subject: [PATCH 6/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/Mod/CAM/Path/Op/Vcarve.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mod/CAM/Path/Op/Vcarve.py b/src/Mod/CAM/Path/Op/Vcarve.py index 6d92417ca4..0454cb00ea 100644 --- a/src/Mod/CAM/Path/Op/Vcarve.py +++ b/src/Mod/CAM/Path/Op/Vcarve.py @@ -457,7 +457,7 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): obj.Discretize = 0.25 obj.Tolerance = Path.Preferences.defaultGeometryTolerance() # keep copy in local object to use in methods which do not operate directly on obj - # we use getattr because OpsDefaultEditor may trigger this method to gather list of + # we use getattr because OpsDefaultEditor may trigger this method to gather list of # default operation settings but reading from OpPrototype object fails self.Tolerance = getattr(obj, "Tolerance", Path.Preferences.defaultGeometryTolerance()) self.setupAdditionalProperties(obj) From a0fbcbde14084a998fe30805b9315c3d5f53e21e Mon Sep 17 00:00:00 2001 From: Phaseloop Date: Sat, 15 Nov 2025 17:22:26 +0100 Subject: [PATCH 7/7] fix broken backtrack edge generation --- src/Mod/CAM/Path/Op/Vcarve.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Mod/CAM/Path/Op/Vcarve.py b/src/Mod/CAM/Path/Op/Vcarve.py index 6d92417ca4..68604c7d20 100644 --- a/src/Mod/CAM/Path/Op/Vcarve.py +++ b/src/Mod/CAM/Path/Op/Vcarve.py @@ -164,7 +164,7 @@ def generateVirtualBackTrackEdges(positionHistory, nextEdge, tolerance) -> list: to follow them without lifting toolbit. This approach makes carving a lot of faster. """ - if not positionHistory: + if not positionHistory or len(positionHistory) < 2: return [] backTrackEdges = [] @@ -591,6 +591,8 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): # but using some routing logic we may avoid raising CNC toolbit and using G0 # and instead traverse back already carved edges at full speed + backtrack_edges = generateVirtualBackTrackEdges(positionHistory, wire[0], obj.Tolerance) + edge_list = backtrack_edges + wire e = edge_list[0]