CAM: Configure Helix via CutMode, not Direction (#14314)

To harmonize the various CAM operations, the helical drill shall also be
configured using the cut mode (Climb/Conventional) just like Pocket or
FaceMill.
This means, the path direction (CW/CCW) is now a hidden read-only output
property, calculated from the side (Inside/OutSide) and the here newly
introduced cut mode.
The spindle direction is not yet taken into account and is always assumed
to be "Forward".
The GUI strings are kept compatible with what RP#14364 introduced becasue
of the v1.0 release string freeze. They need revision once back on the
main branch.
This commit is contained in:
Jonas Bähr
2024-11-10 17:39:36 +01:00
parent 61d6492418
commit dda53d6e67
5 changed files with 156 additions and 17 deletions

View File

@@ -25,6 +25,7 @@ import pathlib
import Draft
import FreeCAD
import Path
import Path.Base.SetupSheetOpPrototype as PathSetupSheetOpPrototype
import Path.Main.Job as PathJob
import Path.Op.Helix as PathHelix
import CAMTests.PathTestUtils as PathTestUtils
@@ -55,6 +56,12 @@ class TestPathHelix(PathTestUtils.PathTestBase):
op = PathHelix.Create("Helix")
op.Proxy.execute(op)
def testCreateWithPrototype(self):
"""Verify a Helix can be created on a SetupSheet's prototype instead of a real document object"""
ptt = PathSetupSheetOpPrototype.OpPrototype("Helix")
op = PathHelix.Create("OpPrototype.Helix", ptt)
def test01(self):
"""Verify Helix generates proper holes from model"""
@@ -132,6 +139,33 @@ class TestPathHelix(PathTestUtils.PathTestBase):
round(pos.Length / 10, 0), proxy.holeDiameter(op, model, sub)
)
def testPathDirection(self):
"""Verify that the generated paths obays the given parameters"""
helix = PathHelix.Create("Helix")
def check(start_side, cut_mode, expected_direction):
with self.subTest(f"({start_side}, {cut_mode}) => {expected_direction}"):
helix.StartSide = start_side
helix.CutMode = cut_mode
self.assertSuccessfulRecompute(self.doc, helix)
self.assertEqual(
helix.Direction,
expected_direction,
msg=f"Direction was not correctly determined",
)
self.assertPathDirection(
helix.Path,
expected_direction,
msg=f"Path with wrong direction generated",
)
check("Inside", "Conventional", "CW")
check("Outside", "Climb", "CW")
check("Inside", "Climb", "CCW")
check("Outside", "Conventional", "CCW")
def testRecomputeHelixFromV021(self):
"""Verify that we can still open and recompute a Helix created with older FreeCAD"""
self.tearDown()
@@ -139,39 +173,68 @@ class TestPathHelix(PathTestUtils.PathTestBase):
created_with = f"created with {self.doc.getProgramVersion()}"
def check(helix, direction, start_side, cut_mode):
with self.subTest(f"{helix.Name}: {direction}, {start_side}, {cut_mode}"):
with self.subTest(f"{helix.Name}: ({direction}, {start_side}) => {cut_mode}"):
# no recompute yet, i.e. check original as precondition
self.assertPathDirection(
helix.Path,
direction,
msg=f"Path direction does not match fixture for {helix.Name} {created_with}",
)
self.assertEqual(
helix.Direction,
direction,
msg=f"Direction does not match fixture for helix {created_with}",
msg=f"Direction does not match fixture for {helix.Name} {created_with}",
)
self.assertEqual(
helix.StartSide,
start_side,
msg=f"StartSide does not match fixture for helix {created_with}",
msg=f"StartSide does not match fixture for {helix.Name} {created_with}",
)
# now see whether we can recompute the object from the old document
helix.enforceRecompute()
self.assertSuccessfulRecompute(
self.doc, helix, msg=f"Cannot recompute helix {created_with}"
self.doc, helix, msg=f"Cannot recompute {helix.Name} {created_with}"
)
self.assertEqual(
helix.Direction,
direction,
msg=f"Direction changed after recomputing helix {created_with}",
msg=f"Direction changed after recomputing {helix.Name} {created_with}",
)
self.assertEqual(
helix.StartSide,
start_side,
msg=f"StartSide changed after recomputing helix {created_with}",
msg=f"StartSide changed after recomputing {helix.Name} {created_with}",
)
self.assertEqual(
helix.CutMode,
cut_mode,
msg=f"CutMode not correctly derived for {helix.Name} {created_with}",
)
self.assertPathDirection(
helix.Path,
direction,
msg=f"Path with wrong direction generated for {helix.Name} {created_with}",
)
# self.assertEqual(helix.CutMode, cut_mode,
# msg=f"CutMode not correctly derived for helix {created_with}")
# object names and expected values defined in the fixture
check(self.doc.Helix, "CW", "Inside", "Conventional")
check(self.doc.Helix001, "CW", "Outside", "Climb")
check(self.doc.Helix002, "CCW", "Inside", "Climb")
check(self.doc.Helix003, "CCW", "Outside", "Conventional")
def assertPathDirection(self, path, expected_direction, msg=None):
"""Asserts that the given path goes into the expected direction.
For the general case we'd need to check the sign of the second derivative,
but as we know we work on a helix here, we can take a short cut and just
look at the G2/G3 arc commands.
"""
has_g2 = any(filter(lambda cmd: cmd.Name == "G2", path.Commands))
has_g3 = any(filter(lambda cmd: cmd.Name == "G3", path.Commands))
if has_g2 and not has_g3:
self.assertEqual("CW", expected_direction, msg)
elif has_g3 and not has_g2:
self.assertEqual("CCW", expected_direction, msg)
else:
raise NotImplementedError("Cannot determine direction for arbitrary paths")

View File

@@ -89,18 +89,18 @@
</widget>
</item>
<item row="2" column="1">
<widget class="QComboBox" name="direction">
<widget class="QComboBox" name="cutMode">
<property name="toolTip">
<string>The direction for the helix, clockwise or counterclockwise.</string>
</property>
<item>
<property name="text">
<string>CW</string>
<string>Climb</string>
</property>
</item>
<item>
<property name="text">
<string>CCW</string>
<string>Conventional</string>
</property>
</item>
</widget>

View File

@@ -224,6 +224,9 @@ class OpPrototype(object):
def setEditorMode(self, name, mode):
self.properties[name].setEditorMode(mode)
def setPropertyStatus(self, name, status):
pass
def getProperty(self, name):
return self.properties[name]

View File

@@ -52,7 +52,7 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
"""getForm() ... return UI"""
form = FreeCADGui.PySideUic.loadUi(":/panels/PageOpHelixEdit.ui")
comboToPropertyMap = [("startSide", "StartSide"), ("direction", "Direction")]
comboToPropertyMap = [("startSide", "StartSide"), ("cutMode", "CutMode")]
enumTups = PathHelix.ObjectHelix.helixOpPropertyEnumerations(dataType="raw")
@@ -62,8 +62,8 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
def getFields(self, obj):
"""getFields(obj) ... transfers values from UI to obj's properties"""
Path.Log.track()
if obj.Direction != str(self.form.direction.currentData()):
obj.Direction = str(self.form.direction.currentData())
if obj.CutMode != str(self.form.cutMode.currentData()):
obj.CutMode = str(self.form.cutMode.currentData())
if obj.StartSide != str(self.form.startSide.currentData()):
obj.StartSide = str(self.form.startSide.currentData())
if obj.StepOver != self.form.stepOverPercent.value():
@@ -78,7 +78,7 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
Path.Log.track()
self.form.stepOverPercent.setValue(obj.StepOver)
self.selectInComboBox(obj.Direction, self.form.direction)
self.selectInComboBox(obj.CutMode, self.form.cutMode)
self.selectInComboBox(obj.StartSide, self.form.startSide)
self.setupToolController(obj, self.form.toolController)
@@ -94,7 +94,7 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
signals.append(self.form.stepOverPercent.editingFinished)
signals.append(self.form.extraOffset.editingFinished)
signals.append(self.form.direction.currentIndexChanged)
signals.append(self.form.cutMode.currentIndexChanged)
signals.append(self.form.startSide.currentIndexChanged)
signals.append(self.form.toolController.currentIndexChanged)
signals.append(self.form.coolantController.currentIndexChanged)

View File

@@ -51,6 +51,36 @@ else:
translate = FreeCAD.Qt.translate
def _caclulatePathDirection(mode, side):
"""Calculates the path direction from cut mode and cut side"""
# NB: at the time of writing, we need py3.8 compat, thus not using py3.10 pattern machting
if mode == "Conventional" and side == "Inside":
return "CW"
elif mode == "Conventional" and side == "Outside":
return "CCW"
elif mode == "Climb" and side == "Inside":
return "CCW"
elif mode == "Climb" and side == "Outside":
return "CW"
else:
raise ValueError(f"No mapping for '{mode}'/'{side}'")
def _caclulateCutMode(direction, side):
"""Calculates the cut mode from path direction and cut side"""
# NB: at the time of writing, we need py3.8 compat, thus not using py3.10 pattern machting
if direction == "CW" and side == "Inside":
return "Conventional"
elif direction == "CW" and side == "Outside":
return "Climb"
elif direction == "CCW" and side == "Inside":
return "Climb"
elif direction == "CCW" and side == "Outside":
return "Conventional"
else:
raise ValueError(f"No mapping for '{direction}'/'{side}'")
class ObjectHelix(PathCircularHoleBase.ObjectOp):
"""Proxy class for Helix operations."""
@@ -75,6 +105,10 @@ class ObjectHelix(PathCircularHoleBase.ObjectOp):
(translate("PathProfile", "Outside"), "Outside"),
(translate("PathProfile", "Inside"), "Inside"),
], # side of profile that cutter is on in relation to direction of profile
"CutMode": [
(translate("CAM_Helix", "Climb"), "Climb"),
(translate("CAM_Helix", "Conventional"), "Conventional"),
], # whether the tool "rolls" with or against the feed direction along the profile
}
if dataType == "raw":
@@ -106,6 +140,8 @@ class ObjectHelix(PathCircularHoleBase.ObjectOp):
"The direction of the circular cuts, ClockWise (CW), or CounterClockWise (CCW)",
),
)
obj.setEditorMode("Direction", ["ReadOnly", "Hidden"])
obj.setPropertyStatus("Direction", ["ReadOnly", "Output"])
obj.addProperty(
"App::PropertyEnumeration",
@@ -114,6 +150,17 @@ class ObjectHelix(PathCircularHoleBase.ObjectOp):
QT_TRANSLATE_NOOP("App::Property", "Start cutting from the inside or outside"),
)
# TODO: revise property description once v1.0 release string freeze is lifted
obj.addProperty(
"App::PropertyEnumeration",
"CutMode",
"Helix Drill",
QT_TRANSLATE_NOOP(
"App::Property",
"The direction of the circular cuts, ClockWise (Climb), or CounterClockWise (Conventional)",
),
)
obj.addProperty(
"App::PropertyPercent",
"StepOver",
@@ -163,9 +210,34 @@ class ObjectHelix(PathCircularHoleBase.ObjectOp):
),
)
if not hasattr(obj, "CutMode"):
# TODO: consolidate the duplicate definitions from opOnDocumentRestored and
# initCircularHoleOperation once back on the main line
obj.addProperty(
"App::PropertyEnumeration",
"CutMode",
"Helix Drill",
QT_TRANSLATE_NOOP(
"App::Property",
"The direction of the circular cuts, ClockWise (Climb), or CounterClockWise (Conventional)",
),
)
obj.CutMode = ["Climb", "Conventional"]
if obj.Direction in ["Climb", "Conventional"]:
# For some month, late in the v1.0 release cycle, we had the cut mode assigned
# to the direction (see PR#14364). Let's fix files created in this time as well.
new_dir = "CW" if obj.Direction == "Climb" else "CCW"
obj.Direction = ["CW", "CCW"]
obj.Direction = new_dir
obj.CutMode = _caclulateCutMode(obj.Direction, obj.StartSide)
obj.setEditorMode("Direction", ["ReadOnly", "Hidden"])
obj.setPropertyStatus("Direction", ["ReadOnly", "Output"])
def circularHoleExecute(self, obj, holes):
"""circularHoleExecute(obj, holes) ... generate helix commands for each hole in holes"""
Path.Log.track()
obj.Direction = _caclulatePathDirection(obj.CutMode, obj.StartSide)
self.commandlist.append(Path.Command("(helix cut operation)"))
self.commandlist.append(Path.Command("G0", {"Z": obj.ClearanceHeight.Value}))
@@ -217,8 +289,9 @@ class ObjectHelix(PathCircularHoleBase.ObjectOp):
def SetupProperties():
"""Returns property names for which the "Setup Sheet" should provide defaults."""
setup = []
setup.append("Direction")
setup.append("CutMode")
setup.append("StartSide")
setup.append("StepOver")
setup.append("StartRadius")