Merge pull request #5470 from sliptonic/bug/bottomfacedrilling

[Path] Bug/bottomfacedrilling
This commit is contained in:
sliptonic
2022-02-01 10:04:36 -06:00
committed by GitHub
8 changed files with 313 additions and 88 deletions

View File

@@ -149,6 +149,7 @@ SET(Generator_SRCS
Generators/drill_generator.py
Generators/rotation_generator.py
Generators/helix_generator.py
Generators/toolchange_generator.py
)
SET(PathScripts_post_SRCS
@@ -240,6 +241,7 @@ SET(PathTests_SRCS
PathTests/TestPathPropertyBag.py
PathTests/TestPathSetupSheet.py
PathTests/TestPathStock.py
PathTests/TestPathToolChangeGenerator.py
PathTests/TestPathThreadMilling.py
PathTests/TestPathTool.py
PathTests/TestPathToolBit.py

View File

@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2021 sliptonic <shopinthewoods@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program is distributed in the hope that it will be useful, *
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
# * GNU Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import PathScripts.PathLog as PathLog
import Path
from enum import Enum
__title__ = "Toolchange Path Generator"
__author__ = "sliptonic (Brad Collette)"
__url__ = "https://www.freecadweb.org"
__doc__ = "Generates the rotation toolpath"
if False:
PathLog.setLevel(PathLog.Level.DEBUG, PathLog.thisModule())
PathLog.trackModule(PathLog.thisModule())
else:
PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule())
class SpindleDirection(Enum):
OFF = "OFF"
CW = "M3"
CCW = "M4"
def generate(
toolnumber, toollabel, spindlespeed=0, spindledirection=SpindleDirection.OFF
):
"""
Generates Gcode for a simple toolchange.
"""
PathLog.track(
f"toolnumber:{toolnumber} toollabel: {toollabel} spindlespeed:{spindlespeed} spindledirection: {spindledirection}"
)
if spindledirection is not SpindleDirection.OFF and spindlespeed == 0:
spindledirection = SpindleDirection.OFF
# raise ValueError("Turning on spindle with zero speed is invalid")
if spindlespeed < 0:
raise ValueError("Spindle speed must be a positive value")
commands = []
commands.append(Path.Command(f"({toollabel})"))
commands.append(Path.Command("M6", {"T": int(toolnumber)}))
if spindledirection is SpindleDirection.OFF:
return commands
else:
commands.append(Path.Command(spindledirection.value, {"S": spindlespeed}))
PathLog.track(commands)
return commands

View File

@@ -28,6 +28,8 @@ import Path
import PathScripts.PathLog as PathLog
import PathScripts.PathPreferences as PathPreferences
import PathScripts.PathToolBit as PathToolBit
from Generators import toolchange_generator as toolchange_generator
from Generators.toolchange_generator import SpindleDirection
if False:
@@ -129,6 +131,7 @@ class ToolController:
# Enumeration lists for App::PropertyEnumeration properties
enums = {
"SpindleDir": [
(translate("Path_ToolController", "None"), "None"),
(translate("Path_ToolController", "Forward"), "Forward"),
(translate("Path_ToolController", "Reverse"), "Reverse"),
], # this is the direction that the profile runs
@@ -259,27 +262,30 @@ class ToolController:
def execute(self, obj):
PathLog.track()
commands = ""
commands += "(" + obj.Label + ")" + "\n"
commands += "M6 T" + str(obj.ToolNumber) + "\n"
args = {
"toolnumber": obj.ToolNumber,
"toollabel": obj.Label,
"spindlespeed": obj.SpindleSpeed,
"spindledirection": SpindleDirection.OFF,
}
# If a toolbit is used, check to see if spindlepower is allowed.
# This is to prevent accidentally spinning the spindle with an
# unpowered tool like probe or dragknife
allowSpindlePower = True
if not isinstance(obj.Tool, Path.Tool) and hasattr(obj.Tool, "SpindlePower"):
allowSpindlePower = obj.Tool.SpindlePower
if allowSpindlePower:
PathLog.debug("selected tool preventing spindle power")
if obj.SpindleDir == "Forward":
commands += "M3 S" + str(obj.SpindleSpeed) + "\n"
if hasattr(obj.Tool, "SpindlePower"):
if not obj.Tool.SpindlePower:
args["spindledirection"] = SpindleDirection.OFF
else:
commands += "M4 S" + str(obj.SpindleSpeed) + "\n"
if obj.SpindleDir == "Forward":
args["spindledirection"] = SpindleDirection.CW
else:
args["spindledirection"] = SpindleDirection.CCW
elif obj.SpindleDir == "None":
args["spindledirection"] = SpindleDirection.OFF
else:
if obj.SpindleDir == "Forward":
args["spindledirection"] = SpindleDirection.CW
else:
args["spindledirection"] = SpindleDirection.CCW
if commands == "":
commands += "(No commands processed)"
commands = toolchange_generator.generate(**args)
path = Path.Path(commands)
obj.Path = path

View File

@@ -11,6 +11,34 @@ else:
PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule())
def checkForBlindHole(baseshape, selectedFace):
"""
check for blind holes, returns the bottom face if found, none
if the hole is a thru-hole
"""
circularFaces = [
f
for f in baseshape.Faces
if len(f.OuterWire.Edges) == 1
and type(f.OuterWire.Edges[0].Curve) == Part.Circle
]
circularFaceEdges = [f.OuterWire.Edges[0] for f in circularFaces]
commonedges = [
i for i in selectedFace.Edges for x in circularFaceEdges if i.isSame(x)
]
bottomface = None
for f in circularFaces:
for e in f.Edges:
for i in commonedges:
if e.isSame(i):
bottomface = f
break
return bottomface
def isDrillableCylinder(obj, candidate, tooldiameter=None, vector=App.Vector(0, 0, 1)):
"""
checks if a candidate cylindrical face is drillable
@@ -69,10 +97,20 @@ def isDrillableCylinder(obj, candidate, tooldiameter=None, vector=App.Vector(0,
if not matchToolDiameter and not matchVector:
return True
elif matchToolDiameter and tooldiameter / 2 > candidate.Surface.Radius:
if matchToolDiameter and tooldiameter / 2 > candidate.Surface.Radius:
PathLog.debug("The tool is larger than the target")
return False
bottomface = checkForBlindHole(obj, candidate)
PathLog.track("candidate is a blind hole")
if (
bottomface is not None and matchVector
): # blind holes only drillable at exact vector
result = compareVecs(bottomface.normalAt(0, 0), vector, exact=True)
PathLog.track(result)
return result
elif matchVector and not (compareVecs(getSeam(candidate).Curve.Direction, vector)):
PathLog.debug("The feature is not aligned with the given vector")
return False
@@ -80,7 +118,7 @@ def isDrillableCylinder(obj, candidate, tooldiameter=None, vector=App.Vector(0,
return True
def isDrillableCircle(obj, candidate, tooldiameter=None, vector=App.Vector(0, 0, 1)):
def isDrillableFace(obj, candidate, tooldiameter=None, vector=App.Vector(0, 0, 1)):
"""
checks if a flat face or edge is drillable
"""
@@ -93,36 +131,57 @@ def isDrillableCircle(obj, candidate, tooldiameter=None, vector=App.Vector(0, 0,
)
)
if candidate.ShapeType == "Face":
if not type(candidate.Surface) == Part.Plane:
PathLog.debug("Drilling on non-planar faces not supported")
return False
PathLog.track()
if not type(candidate.Surface) == Part.Plane:
PathLog.debug("Drilling on non-planar faces not supported")
return False
if (
len(candidate.Edges) == 1 and type(candidate.Edges[0].Curve) == Part.Circle
): # Regular circular face
edge = candidate.Edges[0]
elif (
len(candidate.Edges) == 2
and type(candidate.Edges[0].Curve) == Part.Circle
and type(candidate.Edges[1].Curve) == Part.Circle
): # process a donut
e1 = candidate.Edges[0]
e2 = candidate.Edges[1]
edge = e1 if e1.Curve.Radius < e2.Curve.Radius else e2
else:
PathLog.debug(
"expected a Face with one or two circular edges got a face with {} edges".format(
len(candidate.Edges)
)
if (
len(candidate.Edges) == 1 and type(candidate.Edges[0].Curve) == Part.Circle
): # Regular circular face
edge = candidate.Edges[0]
elif (
len(candidate.Edges) == 2
and type(candidate.Edges[0].Curve) == Part.Circle
and type(candidate.Edges[1].Curve) == Part.Circle
): # process a donut
e1 = candidate.Edges[0]
e2 = candidate.Edges[1]
edge = e1 if e1.Curve.Radius < e2.Curve.Radius else e2
else:
PathLog.debug(
"expected a Face with one or two circular edges got a face with {} edges".format(
len(candidate.Edges)
)
)
return False
if vector is not None: # Check for blind hole alignment
if not compareVecs(candidate.normalAt(0, 0), vector, exact=True):
return False
if matchToolDiameter and edge.Curve.Radius < tooldiameter / 2:
PathLog.track()
return False
else:
return True
else: # edge
edge = candidate
if not (isinstance(edge.Curve, Part.Circle) and edge.isClosed()):
PathLog.debug("expected a closed circular edge")
return False
def isDrillableEdge(obj, candidate, tooldiameter=None, vector=App.Vector(0, 0, 1)):
"""
checks if an edge is drillable
"""
matchToolDiameter = tooldiameter is not None
matchVector = vector is not None
PathLog.debug(
"\n match tool diameter {} \n match vector {}".format(
matchToolDiameter, matchVector
)
)
edge = candidate
if not (isinstance(edge.Curve, Part.Circle) and edge.isClosed()):
PathLog.debug("expected a closed circular edge")
return False
if not hasattr(edge.Curve, "Radius"):
PathLog.debug("The Feature edge has no radius - Ellipse.")
@@ -131,11 +190,11 @@ def isDrillableCircle(obj, candidate, tooldiameter=None, vector=App.Vector(0, 0,
if not matchToolDiameter and not matchVector:
return True
elif matchToolDiameter and tooldiameter / 2 > edge.Curve.Radius:
if matchToolDiameter and tooldiameter / 2 > edge.Curve.Radius:
PathLog.debug("The tool is larger than the target")
return False
elif matchVector and not (compareVecs(edge.Curve.Axis, vector)):
if matchVector and not (compareVecs(edge.Curve.Axis, vector)):
PathLog.debug("The feature is not aligned with the given vector")
return False
else:
@@ -175,30 +234,38 @@ def isDrillable(obj, candidate, tooldiameter=None, vector=App.Vector(0, 0, 1)):
raise TypeError("expected a Face or Edge. Got a {}".format(candidate.ShapeType))
try:
if candidate.ShapeType == "Face" and isinstance(
candidate.Surface, Part.Cylinder
):
return isDrillableCylinder(obj, candidate, tooldiameter, vector)
if candidate.ShapeType == "Face":
if isinstance(candidate.Surface, Part.Cylinder):
return isDrillableCylinder(obj, candidate, tooldiameter, vector)
else:
return isDrillableFace(obj, candidate, tooldiameter, vector)
if candidate.ShapeType == "Edge":
return isDrillableEdge(obj, candidate, tooldiameter, vector)
else:
return isDrillableCircle(obj, candidate, tooldiameter, vector)
return False
except TypeError as e:
PathLog.debug(e)
return False
# raise TypeError("{}".format(e))
def compareVecs(vec1, vec2):
def compareVecs(vec1, vec2, exact=False):
"""
compare the two vectors to see if they are aligned for drilling
compare the two vectors to see if they are aligned for drilling.
if exact is True, vectors must match direction. Otherwise,
alignment can indicate the vectors are the same or exactly opposite
"""
angle = vec1.getAngle(vec2)
angle = 0 if math.isnan(angle) else math.degrees(angle)
PathLog.debug("vector angle: {}".format(angle))
return numpy.isclose(angle, 0, rtol=1e-05, atol=1e-06) or numpy.isclose(
angle, 180, rtol=1e-05, atol=1e-06
)
if exact:
return numpy.isclose(angle, 0, rtol=1e-05, atol=1e-06)
else:
return numpy.isclose(angle, 0, rtol=1e-05, atol=1e-06) or numpy.isclose(
angle, 180, rtol=1e-05, atol=1e-06
)
def getDrillableTargets(obj, ToolDiameter=None, vector=App.Vector(0, 0, 1)):

View File

@@ -33,10 +33,11 @@ if False:
else:
PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule())
class TestPathDrillable(PathTestUtils.PathTestBase):
def setUp(self):
self.doc = App.open(App.getHomePath() + "/Mod/Path/PathTests/Drilling_1.FCStd")
self.obj = self.doc.getObject("Pocket010")
self.obj = self.doc.getObject("Pocket011")
def tearDown(self):
App.closeDocument(self.doc.Name)
@@ -68,16 +69,10 @@ class TestPathDrillable(PathTestUtils.PathTestBase):
TypeError, lambda: drillableLib.isDrillable(self.obj.Shape, candidate)
)
# # partial cylinder
# candidate = self.obj.getSubObject("Face10")
# self.assertRaises(
# TypeError, lambda: drillableLib.isDrillable(self.obj.Shape, candidate)
# )
# Test cylinder faces
# thru-hole
candidate = self.obj.getSubObject("Face25")
candidate = self.obj.getSubObject("Face30")
# Typical drilling
self.assertTrue(drillableLib.isDrillable(self.obj.Shape, candidate))
@@ -93,7 +88,7 @@ class TestPathDrillable(PathTestUtils.PathTestBase):
)
# off-axis hole
candidate = self.obj.getSubObject("Face42")
candidate = self.obj.getSubObject("Face44")
# Typical drilling
self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate))
@@ -106,26 +101,26 @@ class TestPathDrillable(PathTestUtils.PathTestBase):
# Passing explicit vector
self.assertTrue(
drillableLib.isDrillable(
self.obj.Shape, candidate, vector=App.Vector(0, 1, 0)
self.obj.Shape, candidate, vector=App.Vector(0, -1, 0)
)
)
# Drilling with smaller bit
self.assertTrue(
drillableLib.isDrillable(
self.obj.Shape, candidate, tooldiameter=10, vector=App.Vector(0, 1, 0)
self.obj.Shape, candidate, tooldiameter=10, vector=App.Vector(0, -1, 0)
)
)
# Drilling with bit too large
self.assertFalse(
drillableLib.isDrillable(
self.obj.Shape, candidate, tooldiameter=30, vector=App.Vector(0, 1, 0)
self.obj.Shape, candidate, tooldiameter=30, vector=App.Vector(0, -1, 0)
)
)
# ellipse hole
candidate = self.obj.getSubObject("Face20")
candidate = self.obj.getSubObject("Face29")
# Typical drilling
self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate))
@@ -136,7 +131,7 @@ class TestPathDrillable(PathTestUtils.PathTestBase):
)
# raised cylinder
candidate = self.obj.getSubObject("Face30")
candidate = self.obj.getSubObject("Face32")
# Typical drilling
self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate))
@@ -146,9 +141,8 @@ class TestPathDrillable(PathTestUtils.PathTestBase):
drillableLib.isDrillable(self.obj.Shape, candidate, vector=None)
)
# cylinder on slope
candidate = self.obj.getSubObject("Face26")
candidate = self.obj.getSubObject("Face24")
# Typical drilling
self.assertTrue(drillableLib.isDrillable(self.obj.Shape, candidate))
@@ -158,7 +152,7 @@ class TestPathDrillable(PathTestUtils.PathTestBase):
)
# Circular Faces
candidate = self.obj.getSubObject("Face51")
candidate = self.obj.getSubObject("Face54")
# Typical drilling
self.assertTrue(drillableLib.isDrillable(self.obj.Shape, candidate))
@@ -186,7 +180,7 @@ class TestPathDrillable(PathTestUtils.PathTestBase):
)
# off-axis circular face hole
candidate = self.obj.getSubObject("Face54")
candidate = self.obj.getSubObject("Face58")
# Typical drilling
self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate))
@@ -199,12 +193,12 @@ class TestPathDrillable(PathTestUtils.PathTestBase):
# Passing explicit vector
self.assertTrue(
drillableLib.isDrillable(
self.obj.Shape, candidate, vector=App.Vector(0, 1, 0)
self.obj.Shape, candidate, vector=App.Vector(0, -1, 0)
)
)
# raised face
candidate = self.obj.getSubObject("Face45")
candidate = self.obj.getSubObject("Face49")
# Typical drilling
self.assertTrue(drillableLib.isDrillable(self.obj.Shape, candidate))
@@ -214,7 +208,7 @@ class TestPathDrillable(PathTestUtils.PathTestBase):
)
# interrupted Face
candidate = self.obj.getSubObject("Face46")
candidate = self.obj.getSubObject("Face50")
# Typical drilling
self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate))
@@ -224,7 +218,7 @@ class TestPathDrillable(PathTestUtils.PathTestBase):
)
# donut face
candidate = self.obj.getSubObject("Face44")
candidate = self.obj.getSubObject("Face48")
# Typical drilling
self.assertTrue(drillableLib.isDrillable(self.obj.Shape, candidate))
@@ -235,7 +229,7 @@ class TestPathDrillable(PathTestUtils.PathTestBase):
# Test edges
# circular edge
candidate = self.obj.getSubObject("Edge53")
candidate = self.obj.getSubObject("Edge55")
# Typical drilling
self.assertTrue(drillableLib.isDrillable(self.obj.Shape, candidate))
@@ -259,11 +253,13 @@ class TestPathDrillable(PathTestUtils.PathTestBase):
# Drilling with bit too large
self.assertFalse(
drillableLib.isDrillable(self.obj.Shape, candidate, tooldiameter=30)
drillableLib.isDrillable(
self.obj.Shape, candidate, tooldiameter=30, vector=None
)
)
# off-axis circular edge
candidate = self.obj.getSubObject("Edge72")
candidate = self.obj.getSubObject("Edge74")
# Typical drilling
self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate))
@@ -281,7 +277,7 @@ class TestPathDrillable(PathTestUtils.PathTestBase):
)
# incomplete circular edge
candidate = self.obj.getSubObject("Edge108")
candidate = self.obj.getSubObject("Edge39")
# Typical drilling
self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate))
@@ -291,7 +287,7 @@ class TestPathDrillable(PathTestUtils.PathTestBase):
)
# elliptical edge
candidate = self.obj.getSubObject("Edge54")
candidate = self.obj.getSubObject("Edge56")
# Typical drilling
self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate))
@@ -300,14 +296,15 @@ class TestPathDrillable(PathTestUtils.PathTestBase):
drillableLib.isDrillable(self.obj.Shape, candidate, vector=None)
)
def test20(self):
"""Test getDrillableTargets"""
results = drillableLib.getDrillableTargets(self.obj)
self.assertEqual(len(results), 15)
results = drillableLib.getDrillableTargets(self.obj, vector=None)
self.assertEqual(len(results), 18)
self.assertEqual(len(results), 20)
results = drillableLib.getDrillableTargets(self.obj, ToolDiameter= 20, vector=None)
results = drillableLib.getDrillableTargets(
self.obj, ToolDiameter=20, vector=None
)
self.assertEqual(len(results), 5)

View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2021 sliptonic <shopinthewoods@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program is distributed in the hope that it will be useful, *
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
# * GNU Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import Path
import Generators.toolchange_generator as generator
from Generators.toolchange_generator import SpindleDirection
import PathScripts.PathLog as PathLog
import PathTests.PathTestUtils as PathTestUtils
PathLog.setLevel(PathLog.Level.DEBUG, PathLog.thisModule())
PathLog.trackModule(PathLog.thisModule())
class TestPathToolChangeGenerator(PathTestUtils.PathTestBase):
def test00(self):
"""Test Basic Tool Change Generator Return"""
args = {
"toolnumber": 1,
"toollabel": "My Label",
"spindlespeed": 500,
"spindledirection": SpindleDirection.OFF,
}
results = generator.generate(**args)
# Get a label
self.assertTrue(len(results) == 2)
commentcommand = results[0]
self.assertTrue(isinstance(commentcommand, Path.Command))
self.assertTrue(commentcommand.toGCode() == "(My Label)")
# Get a tool command
toolcommand = results[1]
self.assertTrue(toolcommand.Name == "M6")
# Turn on the spindle
args["spindledirection"] = SpindleDirection.CW
results = generator.generate(**args)
self.assertTrue(len(results) == 3)
speedcommand = results[2]
self.assertTrue(speedcommand.Name == "M3")
self.assertTrue(speedcommand.Parameters["S"] == 500)
# speed zero with spindle on
args["spindlespeed"] = 0
results = generator.generate(**args)
self.assertTrue(len(results) == 2)
PathLog.track(results)
# negative spindlespeed
args["spindlespeed"] = -10
self.assertRaises(ValueError, generator.generate, **args)

View File

@@ -46,6 +46,7 @@ from PathTests.TestPathStock import TestPathStock
from PathTests.TestPathThreadMilling import TestPathThreadMilling
from PathTests.TestPathTool import TestPathTool
from PathTests.TestPathToolBit import TestPathToolBit
from PathTests.TestPathToolChangeGenerator import TestPathToolChangeGenerator
from PathTests.TestPathToolController import TestPathToolController
from PathTests.TestPathTooltable import TestPathTooltable
from PathTests.TestPathUtil import TestPathUtil
@@ -76,6 +77,7 @@ False if TestPathStock.__name__ else True
False if TestPathThreadMilling.__name__ else True
False if TestPathTool.__name__ else True
False if TestPathToolBit.__name__ else True
False if TestPathToolChangeGenerator.__name__ else True
False if TestPathToolController.__name__ else True
False if TestPathTooltable.__name__ else True
False if TestPathUtil.__name__ else True