From 95ef2d5147402c3c20dde7e086ac2424ed56ec27 Mon Sep 17 00:00:00 2001 From: Dan Henderson Date: Fri, 6 Dec 2024 11:21:49 -0600 Subject: [PATCH] Path: Add G84/G74 Tapping Operation (#8069) --- src/Mod/CAM/CAMTests/TestPathTapGenerator.py | 132 ++++ src/Mod/CAM/CMakeLists.txt | 7 + src/Mod/CAM/Gui/Resources/Path.qrc | 2 + .../CAM/Gui/Resources/icons/CAM_Tapping.svg | 688 ++++++++++++++++++ .../Gui/Resources/panels/PageOpTappingEdit.ui | 145 ++++ src/Mod/CAM/InitGui.py | 14 +- src/Mod/CAM/Path/Base/Generator/tapping.py | 112 +++ src/Mod/CAM/Path/GuiInit.py | 1 + src/Mod/CAM/Path/Op/Gui/Selection.py | 23 +- src/Mod/CAM/Path/Op/Gui/Tapping.py | 187 +++++ src/Mod/CAM/Path/Op/Tapping.py | 289 ++++++++ src/Mod/CAM/TestCAMApp.py | 2 + src/Mod/CAM/Tools/Bit/375-16_Tap.fctb | 19 + src/Mod/CAM/Tools/Shape/tap.fcstd | Bin 0 -> 13271 bytes 14 files changed, 1616 insertions(+), 5 deletions(-) create mode 100644 src/Mod/CAM/CAMTests/TestPathTapGenerator.py create mode 100644 src/Mod/CAM/Gui/Resources/icons/CAM_Tapping.svg create mode 100644 src/Mod/CAM/Gui/Resources/panels/PageOpTappingEdit.ui create mode 100644 src/Mod/CAM/Path/Base/Generator/tapping.py create mode 100644 src/Mod/CAM/Path/Op/Gui/Tapping.py create mode 100644 src/Mod/CAM/Path/Op/Tapping.py create mode 100644 src/Mod/CAM/Tools/Bit/375-16_Tap.fctb create mode 100644 src/Mod/CAM/Tools/Shape/tap.fcstd diff --git a/src/Mod/CAM/CAMTests/TestPathTapGenerator.py b/src/Mod/CAM/CAMTests/TestPathTapGenerator.py new file mode 100644 index 0000000000..527d23f97b --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathTapGenerator.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2021 sliptonic * +# * * +# * 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 LICENSE 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 FreeCAD +import Part +import Path +import Path.Base.Generator.tapping as generator +import CAMTests.PathTestUtils as PathTestUtils + +Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) +Path.Log.trackModule(Path.Log.thisModule()) + + +class TestPathTapGenerator(PathTestUtils.PathTestBase): + def test00(self): + """Test Basic Tap Generator Return""" + v1 = FreeCAD.Vector(0, 0, 10) + v2 = FreeCAD.Vector(0, 0, 0) + + e = Part.makeLine(v1, v2) + + result = generator.generate(e) + + self.assertTrue(type(result) is list) + self.assertTrue(type(result[0]) is Path.Command) + + command = result[0] + + self.assertTrue(command.Name == "G84") + self.assertTrue(command.Parameters["R"] == 10) + self.assertTrue(command.Parameters["X"] == 0) + self.assertTrue(command.Parameters["Y"] == 0) + self.assertTrue(command.Parameters["Z"] == 0) + + # repeat must be > 0 + args = {"edge": e, "repeat": 0} + self.assertRaises(ValueError, generator.generate, **args) + + # repeat must be integer + args = {"edge": e, "repeat": 1.5} + self.assertRaises(ValueError, generator.generate, **args) + + def test10(self): + """Test edge alignment check""" + v1 = FreeCAD.Vector(0, 10, 10) + v2 = FreeCAD.Vector(0, 0, 0) + e = Part.makeLine(v1, v2) + self.assertRaises(ValueError, generator.generate, e) + + v1 = FreeCAD.Vector(0, 0, 0) + v2 = FreeCAD.Vector(0, 0, 10) + e = Part.makeLine(v1, v2) + + self.assertRaises(ValueError, generator.generate, e) + + def test30(self): + """Test Basic Dwell Tap Generator Return""" + v1 = FreeCAD.Vector(0, 0, 10) + v2 = FreeCAD.Vector(0, 0, 0) + + e = Part.makeLine(v1, v2) + + result = generator.generate(e, dwelltime=0.5) + + self.assertTrue(type(result) is list) + self.assertTrue(type(result[0]) is Path.Command) + + command = result[0] + + self.assertTrue(command.Name == "G84") + self.assertTrue(command.Parameters["P"] == 0.5) + + # dwelltime should be a float + args = {"edge": e, "dwelltime": 1} + self.assertRaises(ValueError, generator.generate, **args) + + def test40(self): + """Specifying retract height should set R parameter to specified value""" + v1 = FreeCAD.Vector(0, 0, 10) + v2 = FreeCAD.Vector(0, 0, 0) + + e = Part.makeLine(v1, v2) + + result = generator.generate(e, retractheight=20.0) + + command = result[0] + + self.assertTrue(command.Parameters["R"] == 20.0) + + def test41(self): + """Not specifying retract height should set R parameter to Z position of start point""" + v1 = FreeCAD.Vector(0, 0, 10) + v2 = FreeCAD.Vector(0, 0, 0) + + e = Part.makeLine(v1, v2) + + result = generator.generate(e) + + command = result[0] + + self.assertTrue(command.Parameters["R"] == 10.0) + + def test44(self): + """Non-float retract height should raise ValueError""" + v1 = FreeCAD.Vector(0, 0, 10) + v2 = FreeCAD.Vector(0, 0, 0) + + e = Part.makeLine(v1, v2) + + args = {"edge": e, "retractheight": 1} + self.assertRaises(ValueError, generator.generate, **args) + args = {"edge": e, "retractheight": "1"} + self.assertRaises(ValueError, generator.generate, **args) diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index 48b6bca114..578ca2e551 100644 --- a/src/Mod/CAM/CMakeLists.txt +++ b/src/Mod/CAM/CMakeLists.txt @@ -142,6 +142,7 @@ SET(PathPythonPostScripts_SRCS Path/Post/scripts/comparams_post.py Path/Post/scripts/dxf_post.py Path/Post/scripts/dynapath_post.py + Path/Post/scripts/dynapath_4060_post.py Path/Post/scripts/estlcam_post.py Path/Post/scripts/example_pre.py Path/Post/scripts/fablin_post.py @@ -195,6 +196,7 @@ SET(PathPythonOp_SRCS Path/Op/Slot.py Path/Op/Surface.py Path/Op/SurfaceSupport.py + Path/Op/Tapping.py Path/Op/ThreadMilling.py Path/Op/Util.py Path/Op/Vcarve.py @@ -226,6 +228,7 @@ SET(PathPythonOpGui_SRCS Path/Op/Gui/Slot.py Path/Op/Gui/Stop.py Path/Op/Gui/Surface.py + Path/Op/Gui/Tapping.py Path/Op/Gui/ThreadMilling.py Path/Op/Gui/Vcarve.py Path/Op/Gui/Waterline.py @@ -244,6 +247,7 @@ SET(PathPythonBaseGenerator_SRCS Path/Base/Generator/drill.py Path/Base/Generator/helix.py Path/Base/Generator/rotation.py + Path/Base/Generator/tapping.py Path/Base/Generator/threadmilling.py Path/Base/Generator/toolchange.py ) @@ -258,6 +262,7 @@ SET(Tools_SRCS ) SET(Tools_Bit_SRCS + Tools/Bit/375-16_Tap.fctb Tools/Bit/45degree_chamfer.fctb Tools/Bit/5mm-thread-cutter.fctb Tools/Bit/5mm_Drill.fctb @@ -282,6 +287,7 @@ SET(Tools_Shape_SRCS Tools/Shape/endmill.fcstd Tools/Shape/probe.fcstd Tools/Shape/slittingsaw.fcstd + Tools/Shape/tap.fcstd Tools/Shape/thread-mill.fcstd Tools/Shape/v-bit.fcstd ) @@ -329,6 +335,7 @@ SET(Tests_SRCS CAMTests/TestPathRotationGenerator.py CAMTests/TestPathSetupSheet.py CAMTests/TestPathStock.py + CAMTests/TestPathTapGenerator.py CAMTests/TestPathToolChangeGenerator.py CAMTests/TestPathThreadMilling.py CAMTests/TestPathThreadMillingGenerator.py diff --git a/src/Mod/CAM/Gui/Resources/Path.qrc b/src/Mod/CAM/Gui/Resources/Path.qrc index 07cf8cedf0..b8462b1868 100644 --- a/src/Mod/CAM/Gui/Resources/Path.qrc +++ b/src/Mod/CAM/Gui/Resources/Path.qrc @@ -52,6 +52,7 @@ icons/CAM_SimulatorGL.svg icons/CAM_Slot.svg icons/CAM_Stop.svg + icons/CAM_Tapping.svg icons/CAM_ThreadMilling.svg icons/CAM_ToolBit.svg icons/CAM_ToolChange.svg @@ -108,6 +109,7 @@ panels/PageOpProfileFullEdit.ui panels/PageOpSlotEdit.ui panels/PageOpSurfaceEdit.ui + panels/PageOpTappingEdit.ui panels/PageOpThreadMillingEdit.ui panels/PageOpWaterlineEdit.ui panels/PageOpVcarveEdit.ui diff --git a/src/Mod/CAM/Gui/Resources/icons/CAM_Tapping.svg b/src/Mod/CAM/Gui/Resources/icons/CAM_Tapping.svg new file mode 100644 index 0000000000..aeda170b5e --- /dev/null +++ b/src/Mod/CAM/Gui/Resources/icons/CAM_Tapping.svg @@ -0,0 +1,688 @@ + + + + + CAM_Tapping.svg + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + CAM_Tapping.svg + Path_Drilling + 2015-07-04 + http://www.freecadweb.org/wiki/index.php?title=Artwork + + + FreeCAD + + + FreeCAD/src/Mod/Path/Gui/Resources/icons/CAM_Tapping.svg + + + FreeCAD LGPL2+ + + + https://www.gnu.org/copyleft/lesser.html + + + [agryson] Alexander Gryson + + + + + + [luvtofish] Dan Henderson + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Mod/CAM/Gui/Resources/panels/PageOpTappingEdit.ui b/src/Mod/CAM/Gui/Resources/panels/PageOpTappingEdit.ui new file mode 100644 index 0000000000..dadc2288d4 --- /dev/null +++ b/src/Mod/CAM/Gui/Resources/panels/PageOpTappingEdit.ui @@ -0,0 +1,145 @@ + + + Form + + + + 0 + 0 + 532 + 351 + + + + Form + + + + + + + + false + + + Time + + + + + + + Dwell + + + + + + + + None + + + + + Tap Tip + + + + + 2x Tap Tip + + + + + + + + false + + + + + + + Extend Depth + + + + + + + + + + 0 + 0 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + <html><head/><body><p>The tool and its settings to be used for this operation.</p></body></html> + + + + + + + Coolant Mode + + + + + + + ToolController + + + + + + + <html><head/><body><p>The tool and its settings to be used for this operation.</p></body></html> + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + Gui::QuantitySpinBox + QWidget +
Gui/QuantitySpinBox.h
+
+
+ + toolController + dwellEnabled + + + +
diff --git a/src/Mod/CAM/InitGui.py b/src/Mod/CAM/InitGui.py index 6bc0ed8ffe..aef883fef9 100644 --- a/src/Mod/CAM/InitGui.py +++ b/src/Mod/CAM/InitGui.py @@ -113,13 +113,13 @@ class CAMWorkbench(Workbench): twodopcmdlist = [ "CAM_Profile", "CAM_Pocket_Shape", - "CAM_Drilling", "CAM_MillFace", "CAM_Helix", "CAM_Adaptive", ] threedopcmdlist = ["CAM_Pocket3D"] engravecmdlist = ["CAM_Engrave", "CAM_Deburr", "CAM_Vcarve"] + drillingcmdlist = ["CAM_Drilling", "CAM_Tapping"] modcmdlist = ["CAM_OperationCopy", "CAM_Array", "CAM_SimpleCopy"] dressupcmdlist = [ "CAM_DressupAxisMap", @@ -145,7 +145,14 @@ class CAMWorkbench(Workbench): QT_TRANSLATE_NOOP("CAM_EngraveTools", "Engraving Operations"), ), ) - + drillingcmdgroup = ["CAM_DrillingTools"] + FreeCADGui.addCommand( + "CAM_DrillingTools", + PathCommandGroup( + drillingcmdlist, + QT_TRANSLATE_NOOP("CAM_DrillingTools", "Drilling Operations"), + ), + ) threedcmdgroup = threedopcmdlist if Path.Preferences.experimentalFeaturesEnabled(): prepcmdlist.append("CAM_Shape") @@ -196,7 +203,7 @@ class CAMWorkbench(Workbench): self.appendToolbar(QT_TRANSLATE_NOOP("Workbench", "Tool Commands"), toolcmdlist) self.appendToolbar( QT_TRANSLATE_NOOP("Workbench", "New Operations"), - twodopcmdlist + engravecmdgroup + threedcmdgroup, + twodopcmdlist + drillingcmdgroup + engravecmdgroup + threedcmdgroup, ) self.appendToolbar(QT_TRANSLATE_NOOP("Workbench", "Path Modification"), modcmdlist) if extracmdlist: @@ -210,6 +217,7 @@ class CAMWorkbench(Workbench): + toolbitcmdlist + ["Separator"] + twodopcmdlist + + drillingcmdlist + engravecmdlist + ["Separator"] + threedopcmdlist diff --git a/src/Mod/CAM/Path/Base/Generator/tapping.py b/src/Mod/CAM/Path/Base/Generator/tapping.py new file mode 100644 index 0000000000..08a0216b00 --- /dev/null +++ b/src/Mod/CAM/Path/Base/Generator/tapping.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2021 sliptonic * +# * Copyright (c) 2023 luvtofish * +# * * +# * 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 LICENSE 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 numpy + +__title__ = "Tapping Path Generator" +__author__ = "sliptonic (Brad Collette)" +__url__ = "https://www.freecadweb.org" +__doc__ = "Generates the Tapping toolpath for a single spotshape" +__contributors__ = "luvtofish (Dan Henderson)" + + +if False: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + + +def generate(edge, dwelltime=0.0, repeat=1, retractheight=None, righthand=True): + """ + Generates Gcode for tapping a single hole. + + Takes as input an edge. It assumes the edge is trivial with just two vectors. + The edge must be aligned with the Z axes (Vector(0,0,1)) or it is an error. + + The first vertex of the edge will be the startpoint + The second vertex of the edge will be the endpoint. + All other vertices are ignored. + + additionally, you can pass in a dwelltime,and repeat value. + + These will result in appropriate G74 and G84 codes. + + """ + startPoint = edge.Vertexes[0].Point + endPoint = edge.Vertexes[1].Point + + Path.Log.debug(startPoint) + Path.Log.debug(endPoint) + + Path.Log.debug(numpy.isclose(startPoint.sub(endPoint).x, 0, rtol=1e-05, atol=1e-06)) + Path.Log.debug(numpy.isclose(startPoint.sub(endPoint).y, 0, rtol=1e-05, atol=1e-06)) + Path.Log.debug(endPoint) + + if repeat < 1: + raise ValueError("repeat must be 1 or greater") + + if not type(repeat) is int: + raise ValueError("repeat value must be an integer") + + if not type(dwelltime) is float: + raise ValueError("dwelltime must be a float") + + if retractheight is not None and not type(retractheight) is float: + raise ValueError("retractheight must be a float") + + if not ( + numpy.isclose(startPoint.sub(endPoint).x, 0, rtol=1e-05, atol=1e-06) + and (numpy.isclose(startPoint.sub(endPoint).y, 0, rtol=1e-05, atol=1e-06)) + ): + raise ValueError("edge is not aligned with Z axis") + + if startPoint.z < endPoint.z: + raise ValueError("start point is below end point") + + cmdParams = {} + cmdParams["X"] = startPoint.x + cmdParams["Y"] = startPoint.y + cmdParams["Z"] = endPoint.z + cmdParams["R"] = retractheight if retractheight is not None else startPoint.z + + if repeat < 1: + raise ValueError("repeat must be 1 or greater") + + if not type(repeat) is int: + raise ValueError("repeat value must be an integer") + + if repeat > 1: + cmdParams["L"] = repeat + + if dwelltime > 0.0: + cmdParams["P"] = dwelltime + + # Check if tool is lefthand or righthand, set appropriate G-code + if not (righthand): + cmd = "G74" + else: + cmd = "G84" + + return [Path.Command(cmd, cmdParams)] diff --git a/src/Mod/CAM/Path/GuiInit.py b/src/Mod/CAM/Path/GuiInit.py index 533a0da7cd..65a7b798d1 100644 --- a/src/Mod/CAM/Path/GuiInit.py +++ b/src/Mod/CAM/Path/GuiInit.py @@ -73,6 +73,7 @@ def Startup(): from Path.Op.Gui import SimpleCopy from Path.Op.Gui import Slot from Path.Op.Gui import Stop + from Path.Op.Gui import Tapping from Path.Op.Gui import ThreadMilling from Path.Op.Gui import Vcarve from Path.Post import Command diff --git a/src/Mod/CAM/Path/Op/Gui/Selection.py b/src/Mod/CAM/Path/Op/Gui/Selection.py index e36e0b387d..547a6521e4 100644 --- a/src/Mod/CAM/Path/Op/Gui/Selection.py +++ b/src/Mod/CAM/Path/Op/Gui/Selection.py @@ -7,7 +7,7 @@ # * 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. * +# * for detail see the LICENTE text file. * # * * # * This program is distributed in the hope that it will be useful, * # * but WITHOUT ANY WARRANTY; without even the implied warranty of * @@ -29,7 +29,7 @@ import Path import Path.Base.Drillable as Drillable import math -Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) +Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) @@ -132,6 +132,18 @@ class DRILLGate(PathBaseGate): return Drillable.isDrillable(shape, subobj, vector=None, allowPartial=True) +class TAPGate(PathBaseGate): + def allow(self, doc, obj, sub): + Path.Log.debug("obj: {} sub: {}".format(obj, sub)) + if not hasattr(obj, "Shape"): + return False + shape = obj.Shape + subobj = shape.getElement(sub) + if subobj.ShapeType not in ["Edge", "Face"]: + return False + return Drillable.isDrillable(shape, subobj, vector=None) + + class FACEGate(PathBaseGate): def allow(self, doc, obj, sub): isFace = False @@ -270,6 +282,12 @@ def drillselect(): FreeCAD.Console.PrintWarning("Drilling Select Mode\n") +def tapselect(): + FreeCADGui.Selection.addSelectionGate(TAPGate()) + if not Path.Preferences.suppressSelectionModeWarning(): + FreeCAD.Console.PrintWarning("Tapping Select Mode\n") + + def engraveselect(): FreeCADGui.Selection.addSelectionGate(ENGRAVEGate()) if not Path.Preferences.suppressSelectionModeWarning(): @@ -349,6 +367,7 @@ def select(op): opsel["Contour"] = contourselect # deprecated opsel["Deburr"] = chamferselect opsel["Drilling"] = drillselect + opsel["Tapping"] = tapselect opsel["Engrave"] = engraveselect opsel["Helix"] = drillselect opsel["MillFace"] = pocketselect diff --git a/src/Mod/CAM/Path/Op/Gui/Tapping.py b/src/Mod/CAM/Path/Op/Gui/Tapping.py new file mode 100644 index 0000000000..c0f51bc0fb --- /dev/null +++ b/src/Mod/CAM/Path/Op/Gui/Tapping.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2017 sliptonic * +# * * +# * 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 FreeCAD +import FreeCADGui +import Path +import Path.Base.Gui.Util as PathGuiUtil +import Path.Op.Tapping as PathTapping +import Path.Op.Gui.Base as PathOpGui +import Path.Op.Gui.CircularHoleBase as PathCircularHoleBaseGui +import PathGui + +from PySide import QtCore + +__title__ = "Path Tapping Operation UI." +__author__ = "sliptonic (Brad Collette)" +__url__ = "https://www.freecadweb.org" +__doc__ = "UI and Command for Path Tapping Operation." +__contributors__ = "luvtofish" + +if False: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + + +class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage): + """Controller for the tapping operation's page""" + + def initPage(self, obj): + # self.peckDepthSpinBox = PathGuiUtil.QuantitySpinBox( + # self.form.peckDepth, obj, "PeckDepth" + # ) + # self.peckRetractSpinBox = PathGuiUtil.QuantitySpinBox( + # self.form.peckRetractHeight, obj, "RetractHeight" + # ) + self.dwellTimeSpinBox = PathGuiUtil.QuantitySpinBox(self.form.dwellTime, obj, "DwellTime") + + # self.form.chipBreakEnabled.setEnabled(False) + + def registerSignalHandlers(self, obj): + # self.form.peckEnabled.toggled.connect(self.form.peckDepth.setEnabled) + # self.form.peckEnabled.toggled.connect(self.form.dwellEnabled.setDisabled) + # self.form.peckEnabled.toggled.connect(self.setChipBreakControl) + + self.form.dwellEnabled.toggled.connect(self.form.dwellTime.setEnabled) + self.form.dwellEnabled.toggled.connect(self.form.dwellTimelabel.setEnabled) + # self.form.dwellEnabled.toggled.connect(self.form.peckEnabled.setDisabled) + # self.form.dwellEnabled.toggled.connect(self.setChipBreakControl) + + # self.form.peckRetractHeight.setEnabled(True) + # self.form.retractLabel.setEnabled(True) + + # if self.form.peckEnabled.isChecked(): + # self.form.dwellEnabled.setEnabled(False) + # self.form.peckDepth.setEnabled(True) + # self.form.peckDepthLabel.setEnabled(True) + # self.form.chipBreakEnabled.setEnabled(True) + # elif self.form.dwellEnabled.isChecked(): + if self.form.dwellEnabled.isChecked(): + # self.form.peckEnabled.setEnabled(False) + self.form.dwellTime.setEnabled(True) + self.form.dwellTimelabel.setEnabled(True) + + # self.form.chipBreakEnabled.setEnabled(False) + # else: + # self.form.chipBreakEnabled.setEnabled(False) + + # def setChipBreakControl(self): + # self.form.chipBreakEnabled.setEnabled(self.form.peckEnabled.isChecked()) + + def getForm(self): + """getForm() ... return UI""" + form = FreeCADGui.PySideUic.loadUi(":/panels/PageOpTappingEdit.ui") + + comboToPropertyMap = [("ExtraOffset", "ExtraOffset")] + enumTups = PathTapping.ObjectTapping.propertyEnumerations(dataType="raw") + self.populateCombobox(form, enumTups, comboToPropertyMap) + + return form + + def updateQuantitySpinBoxes(self, index=None): + # self.peckDepthSpinBox.updateSpinBox() + # self.peckRetractSpinBox.updateSpinBox() + self.dwellTimeSpinBox.updateSpinBox() + + def getFields(self, obj): + """setFields(obj) ... update obj's properties with values from the UI""" + Path.Log.track() + # self.peckDepthSpinBox.updateProperty() + # self.peckRetractSpinBox.updateProperty() + self.dwellTimeSpinBox.updateProperty() + + if obj.DwellEnabled != self.form.dwellEnabled.isChecked(): + obj.DwellEnabled = self.form.dwellEnabled.isChecked() + # if obj.PeckEnabled != self.form.peckEnabled.isChecked(): + # obj.PeckEnabled = self.form.peckEnabled.isChecked() + # if obj.chipBreakEnabled != self.form.chipBreakEnabled.isChecked(): + # obj.chipBreakEnabled = self.form.chipBreakEnabled.isChecked() + if obj.ExtraOffset != str(self.form.ExtraOffset.currentData()): + obj.ExtraOffset = str(self.form.ExtraOffset.currentData()) + + self.updateToolController(obj, self.form.toolController) + self.updateCoolant(obj, self.form.coolantController) + + def setFields(self, obj): + """setFields(obj) ... update UI with obj properties' values""" + Path.Log.track() + self.updateQuantitySpinBoxes() + + if obj.DwellEnabled: + self.form.dwellEnabled.setCheckState(QtCore.Qt.Checked) + else: + self.form.dwellEnabled.setCheckState(QtCore.Qt.Unchecked) + + # if obj.PeckEnabled: + # self.form.peckEnabled.setCheckState(QtCore.Qt.Checked) + # else: + # self.form.peckEnabled.setCheckState(QtCore.Qt.Unchecked) + # self.form.chipBreakEnabled.setEnabled(False) + + # if obj.chipBreakEnabled: + # self.form.chipBreakEnabled.setCheckState(QtCore.Qt.Checked) + # else: + # self.form.chipBreakEnabled.setCheckState(QtCore.Qt.Unchecked) + + self.selectInComboBox(obj.ExtraOffset, self.form.ExtraOffset) + + self.setupToolController(obj, self.form.toolController) + self.setupCoolant(obj, self.form.coolantController) + + def getSignalsForUpdate(self, obj): + """getSignalsForUpdate(obj) ... return list of signals which cause the receiver to update the model""" + signals = [] + + # signals.append(self.form.peckRetractHeight.editingFinished) + # signals.append(self.form.peckDepth.editingFinished) + signals.append(self.form.dwellTime.editingFinished) + signals.append(self.form.dwellEnabled.stateChanged) + # signals.append(self.form.peckEnabled.stateChanged) + # signals.append(self.form.chipBreakEnabled.stateChanged) + signals.append(self.form.toolController.currentIndexChanged) + signals.append(self.form.coolantController.currentIndexChanged) + signals.append(self.form.ExtraOffset.currentIndexChanged) + + return signals + + +# def updateData(self, obj, prop): +# if prop in ["PeckDepth", "RetractHeight"] and not prop in ["Base", "Disabled"]: +# self.updateQuantitySpinBoxes() + + +Command = PathOpGui.SetupOperation( + "Tapping", + PathTapping.Create, + TaskPanelOpPage, + "CAM_Tapping", + QtCore.QT_TRANSLATE_NOOP("CAM_Tapping", "Tapping"), + QtCore.QT_TRANSLATE_NOOP( + "CAM_Tapping", + "Creates a Tapping toolpath from the features of a base object", + ), + PathTapping.SetupProperties, +) + +FreeCAD.Console.PrintLog("Loading PathTappingGui... done\n") diff --git a/src/Mod/CAM/Path/Op/Tapping.py b/src/Mod/CAM/Path/Op/Tapping.py new file mode 100644 index 0000000000..33bf0e40d3 --- /dev/null +++ b/src/Mod/CAM/Path/Op/Tapping.py @@ -0,0 +1,289 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2014 Yorik van Havre * +# * Copyright (c) 2020 Schildkroet * +# * Copyright (c) 2023 luvtofish * +# * * +# * 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 LICENSE 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 FreeCAD +import Part +import Path +import Path.Base.FeedRate as PathFeedRate +import Path.Base.Generator.tapping as tapping +import Path.Base.MachineState as PathMachineState +import Path.Op.Base as PathOp +import Path.Op.CircularHoleBase as PathCircularHoleBase +import PathScripts.PathUtils as PathUtils +from PySide.QtCore import QT_TRANSLATE_NOOP + +__title__ = "Path Tapping Operation" +__author__ = "sliptonic (Brad Collette)" +__url__ = "https://www.freecadweb.org" +__doc__ = "Path Tapping operation." +__contributors__ = "luvtofish (Dan Henderson)" + +if False: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + +translate = FreeCAD.Qt.translate + + +class ObjectTapping(PathCircularHoleBase.ObjectOp): + """Proxy object for Tapping operation.""" + + @classmethod + def propertyEnumerations(self, dataType="data"): + """helixOpPropertyEnumerations(dataType="data")... return property enumeration lists of specified dataType. + Args: + dataType = 'data', 'raw', 'translated' + Notes: + 'data' is list of internal string literals used in code + 'raw' is list of (translated_text, data_string) tuples + 'translated' is list of translated string literals + """ + + # Enumeration lists for App::PropertyEnumeration properties + enums = { + "ReturnLevel": [ + (translate("CAM_Tapping", "G98"), "G98"), + (translate("CAM_Tapping", "G99"), "G99"), + ], # How high to retract after a tapping move + "ExtraOffset": [ + (translate("CAM_Tapping", "None"), "None"), + (translate("CAM_Tapping", "Drill Tip"), "Drill Tip"), + (translate("CAM_Tapping", "2x Drill Tip"), "2x Drill Tip"), + ], # extra drilling depth to clear tap taper + } + + if dataType == "raw": + return enums + + data = list() + idx = 0 if dataType == "translated" else 1 + + Path.Log.debug(enums) + + for k, v in enumerate(enums): + data.append((v, [tup[idx] for tup in enums[v]])) + Path.Log.debug(data) + + return data + + def circularHoleFeatures(self, obj): + """circularHoleFeatures(obj) ... tapping works on anything, turn on all Base geometries and Locations.""" + return PathOp.FeatureBaseGeometry | PathOp.FeatureLocations | PathOp.FeatureCoolant + + def initCircularHoleOperation(self, obj): + """initCircularHoleOperation(obj) ... add tapping specific properties to obj.""" + obj.addProperty( + "App::PropertyFloat", + "DwellTime", + "Tap", + QT_TRANSLATE_NOOP("App::Property", "The time to dwell at bottom of tapping cycle"), + ) + obj.addProperty( + "App::PropertyBool", + "DwellEnabled", + "Tap", + QT_TRANSLATE_NOOP("App::Property", "Enable dwell"), + ) + obj.addProperty( + "App::PropertyBool", + "AddTipLength", + "Tap", + QT_TRANSLATE_NOOP( + "App::Property", + "Calculate the tip length and subtract from final depth", + ), + ) + obj.addProperty( + "App::PropertyEnumeration", + "ReturnLevel", + "Tap", + QT_TRANSLATE_NOOP("App::Property", "Controls how tool retracts Default=G98"), + ) + obj.addProperty( + "App::PropertyDistance", + "RetractHeight", + "Tap", + QT_TRANSLATE_NOOP( + "App::Property", + "The height where feed starts and height during retract tool when path is finished while in a peck operation", + ), + ) + obj.addProperty( + "App::PropertyEnumeration", + "ExtraOffset", + "Tap", + QT_TRANSLATE_NOOP("App::Property", "How far the tap depth is extended"), + ) + + for n in self.propertyEnumerations(): + setattr(obj, n[0], n[1]) + + def circularHoleExecute(self, obj, holes): + """circularHoleExecute(obj, holes) ... generate tapping operation for each hole in holes.""" + Path.Log.track() + machine = PathMachineState.MachineState() + + if not hasattr(obj.ToolController.Tool, "Pitch") or not hasattr( + obj.ToolController.Tool, "TPI" + ): + Path.Log.error( + translate( + "Path_Tapping", + "Tapping Operation requires a Tap tool with Pitch or TPI", + ) + ) + return + + self.commandlist.append(Path.Command("(Begin Tapping)")) + + # rapid to clearance height + command = Path.Command("G0", {"Z": obj.ClearanceHeight.Value}) + machine.addCommand(command) + self.commandlist.append(command) + + self.commandlist.append(Path.Command("G90")) # Absolute distance mode + + # Calculate offsets to add to target edge + endoffset = 0.0 + if obj.ExtraOffset == "Drill Tip": + endoffset = PathUtils.drillTipLength(self.tool) + elif obj.ExtraOffset == "2x Drill Tip": + endoffset = PathUtils.drillTipLength(self.tool) * 2 + + # http://linuxcnc.org/docs/html/gcode/g-code.html#gcode:g98-g99 + self.commandlist.append(Path.Command(obj.ReturnLevel)) + + holes = PathUtils.sort_locations(holes, ["x", "y"]) + + # This section is technical debt. The computation of the + # target shapes should be factored out for re-use. + # This will likely mean refactoring upstream CircularHoleBase to pass + # spot shapes instead of holes. + + startHeight = obj.StartDepth.Value + self.job.SetupSheet.SafeHeightOffset.Value + + edgelist = [] + for hole in holes: + v1 = FreeCAD.Vector(hole["x"], hole["y"], obj.StartDepth.Value) + v2 = FreeCAD.Vector(hole["x"], hole["y"], obj.FinalDepth.Value - endoffset) + edgelist.append(Part.makeLine(v1, v2)) + + # iterate the edgelist and generate gcode + for edge in edgelist: + + Path.Log.debug(edge) + + # move to hole location + + startPoint = edge.Vertexes[0].Point + + command = Path.Command( + "G0", {"X": startPoint.x, "Y": startPoint.y, "Z": startHeight} + ) # DLH added Z offset to fix issue with Dynapath Control. + self.commandlist.append(command) + machine.addCommand(command) + + # command = Path.Command("G0", {"Z": startHeight}) + # self.commandlist.append(command) + # machine.addCommand(command) + + # command = Path.Command("G1", {"Z": obj.StartDepth.Value}) + # self.commandlist.append(command) + # machine.addCommand(command) + + # Technical Debt: We are assuming the edges are aligned. + # This assumption should be corrected and the necessary rotations + # performed to align the edge with the Z axis for drilling + + # Perform tapping + dwelltime = obj.DwellTime if obj.DwellEnabled else 0.0 + repeat = 1 # technical debt: Add a repeat property for user control + + # Get attribute from obj.tool, assign default and set to bool for passing to generate + isRightHand = getattr(obj.ToolController.Tool, "Rotation", "Right Hand") == "Right Hand" + + try: + tappingcommands = tapping.generate( + edge, dwelltime, repeat, obj.RetractHeight.Value, isRightHand + ) + + except ValueError as e: # any targets that fail the generator are ignored + Path.Log.info(e) + continue + + for command in tappingcommands: + self.commandlist.append(command) + machine.addCommand(command) + + # Cancel canned tapping cycle + self.commandlist.append(Path.Command("G80")) + # command = Path.Command("G0", {"Z": obj.SafeHeight.Value}) DLH- Not needed, adds unnecessary move to Z SafeHeight. + # self.commandlist.append(command) + # machine.addCommand(command) DLH - Not needed. + + # Apply feed rates to commands + PathFeedRate.setFeedRate(self.commandlist, obj.ToolController) + + def opSetDefaultValues(self, obj, job): + """opSetDefaultValues(obj, job) ... set default value for RetractHeight""" + obj.ExtraOffset = "None" + + if hasattr(job.SetupSheet, "RetractHeight"): + obj.RetractHeight = job.SetupSheet.RetractHeight + elif self.applyExpression(obj, "RetractHeight", "StartDepth+SetupSheet.SafeHeightOffset"): + if not job: + obj.RetractHeight = 10 + else: + obj.RetractHeight.Value = obj.StartDepth.Value + 1.0 + + if hasattr(job.SetupSheet, "DwellTime"): + obj.DwellTime = job.SetupSheet.DwellTime + else: + obj.DwellTime = 1 + + +def SetupProperties(): + setup = [] + setup.append("DwellTime") + setup.append("DwellEnabled") + setup.append("AddTipLength") + setup.append("ReturnLevel") + setup.append("ExtraOffset") + setup.append("RetractHeight") + return setup + + +def Create(name, obj=None, parentJob=None): + """Create(name) ... Creates and returns a Tapping operation.""" + if obj is None: + obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython", name) + + obj.Proxy = ObjectTapping(obj, name, parentJob) + if obj.Proxy: + obj.Proxy.findAllHoles(obj) + + return obj diff --git a/src/Mod/CAM/TestCAMApp.py b/src/Mod/CAM/TestCAMApp.py index ee129b4d8a..7b7ed471e7 100644 --- a/src/Mod/CAM/TestCAMApp.py +++ b/src/Mod/CAM/TestCAMApp.py @@ -58,6 +58,7 @@ from CAMTests.TestPathPropertyBag import TestPathPropertyBag from CAMTests.TestPathRotationGenerator import TestPathRotationGenerator from CAMTests.TestPathSetupSheet import TestPathSetupSheet from CAMTests.TestPathStock import TestPathStock +from CAMTests.TestPathTapGenerator import TestPathTapGenerator from CAMTests.TestPathThreadMilling import TestPathThreadMilling from CAMTests.TestPathThreadMillingGenerator import TestPathThreadMillingGenerator from CAMTests.TestPathToolBit import TestPathToolBit @@ -110,6 +111,7 @@ False if TestPathPropertyBag.__name__ else True False if TestPathRotationGenerator.__name__ else True False if TestPathSetupSheet.__name__ else True False if TestPathStock.__name__ else True +False if TestPathTapGenerator.__name__ else True False if TestPathThreadMilling.__name__ else True False if TestPathThreadMillingGenerator.__name__ else True False if TestPathToolBit.__name__ else True diff --git a/src/Mod/CAM/Tools/Bit/375-16_Tap.fctb b/src/Mod/CAM/Tools/Bit/375-16_Tap.fctb new file mode 100644 index 0000000000..e4032a36c8 --- /dev/null +++ b/src/Mod/CAM/Tools/Bit/375-16_Tap.fctb @@ -0,0 +1,19 @@ +{ + "version": 2, + "name": "375-16_Tap", + "shape": "tap.fcstd", + "parameter": { + "Coating": "None", + "CuttingEdgeLength": "1.063 \"", + "Diameter": "0.375 \"", + "Flutes": "3", + "Length": "2.500 \"", + "Pitch": "0.000 in", + "Rotation": "Right Hand", + "ShankDiameter": "0.250 \"", + "TPI": "16", + "TipAngle": "90.000 \u00b0", + "Type": "HSS" + }, + "attribute": {} +} diff --git a/src/Mod/CAM/Tools/Shape/tap.fcstd b/src/Mod/CAM/Tools/Shape/tap.fcstd new file mode 100644 index 0000000000000000000000000000000000000000..32634aeb580b9b677f2e9cf44e53342cd2a7a0d1 GIT binary patch literal 13271 zcmai*1ymeewzeAxPH=a3hv4q+?oM!bcLKrP-GjSJaCdhnxD)h}Z~nRSC39!~)6}X% zuk&=hRcpU}PVHSSCkgTa82|u4007GTD(Dwxvcw1gz)(H_0Q3H?kd2{}m9e!Wt*e#g zDUOBB3fq;3PtZ7CgG<-a)c4qOt+o(mvj(78X%_MDhHzp0m74N$B8=E{=KEtCu`u^o zM&qH99galxgO8&Lz-_sKl$x62Q*5EdQ&Z(4PtSgIbJwp$Sl7!zP;Rz zP4O9O_#80@GM$C*ickinV|-UZ(eL}9vzi1`Job7JR8ArX7VPGX3}xH zcF?fXK0oROUBt)~T^Fte6C9CGpMEysRm&no*H}w*JoQlAa1X^B!clIXLe^d=@Oj-2 zd@~m}9bb*qr7G9m5W8^r@PgWa$Ki~h;VQ|BU5DIGvi36(iw$6NBNd*yLwhfuWja)k zd%_R>@tN~^KDOro^x3OT4I6iwr7;K2)l8iKbI^$1>my*_#B-ZFyN?=+E(Ir(fIr~o^H`E^4ook&;{~!n*oLOs z+4YFmL}U3RZYR}?L0f)&;`ZJ*cJ&*(>_F@+?wm;N%<|~dQ z@GPF8Cnp`u1C_f0Pc7FhCnj+Av7HQu)F`Cz(Wb|og83V1c32NwH}7)JsH2N@;4Hr8 zaaVk$1|Azo3nztugrtd9Z&-1&?_h`aI8hTbz{&1}1uT02pT4wA>hyTu$$ogW1-;jYjk#k*vJhvo{@clk|OP+F4C zW0Nz|ScBRE5Y^Q3@9VucqMtxV7mz^ka9CeQ%7pn49+47qmzpzEI%@TXyo?K5LYHGAjw#U?wwslr~@XU zET@l|pTv)bR{$?}Sw|PLm{p%pq-OrQytWv%?)fE9ACrfAHC9Bm>O8#$ta0PSmYE8J z|By+E#LZN?c2L43Y1t%*LR4;A9P3%!r-S$P>;uWBxBA@XaVc0<=c?`m4Qnlxz1qvO zlW`o~S5i@MgTqi#@hJjRISoD?X2b6JsT)wufhKq>SyP%tp8i<9r%(;Et5)W4~bX11>!Siv~$>~uZ zwM;WH2z)cn=rfP$q*s(%Fv;3BR&>v7shAGO5h1S#O0OfZu*bX7n}QXJA1k_ZNw%m^ z%zMnp`X=>fmnKBIta}n`aqVbM;Ou?EY&o1Oyj;YcG4=UKIV+5qVl)`!(ricV{D}-N zA3n|}>?M$MgM`~2tt?Dz!3gOGF2k^k5|Jzxd}X7m?l!7Y5NO4(VkCZj^?>-n1fd&! z;PN^>9{z3Di_542(nQ#^?F4fja|ziMZKC-3J)=0skz;frXeZcWEGz{rd_C za>D)H?wojG)IKhV>j90Q$zrO8jA(-K_sJG>k-1?I5p9X{Z(Wh78Rrm`xgS8n(uJr+ zC7)@6A4dXqUhYAYnL@$VTdPPdzgH}V{@4?djp1iKV;m$Tj5xXAA|eIiexpHEM}84+ zJK7@IC4A_C6u{ku7&_z*VxucOLW8F2Nwi+w_neDgDG;I&yAa!|4C#cD!OZpqZBz2X z6{zi3(CMudY(;0=oB@_mmnteO2?I(}9fM>-EaE8rKo1zmo~ zu2`GZZ23;Kk`%%g7T;9l_=4k!jKh8p^7>>te*IRR^+w>3NtJ#;dC&~jPYIG~=zL8D z`=E+_Q3>$uzn~y7!kO;KP!CT=BXT18!kXPVXc6hk_c0$bkQ%JK$;$N5G(G>gO9`_U z9$tAby<+(FareXcWC|z2idPoOh)B{%?6gci{1GpTO8FrZfo2SO0#|#ueXz8-PUn(N zzIs(X-Px10nci1htZ)Rh9&6wpPCIza(Y_JUtNEEDr8sVP+R*x)mf3zsKNI4WL<^9q zM3~`(spL#y=72i)R2NqJ1kr};n3eR?uw52YPST%UwyW{au-HW;c&{ipk+7-q@vH=~ zP<9c@3Le}k$nrwty|kFqn^D6nQ$Ir?Q2jhALXl)LufGsTe1iVcDPLABp*e!Gz$Ao> zn-yMSZ4+ulT}eqvV{yOk>d>vn`$f@yfz0BSRd6*;uZ7MKT*$y_!ojdxJEa8jrx)dw zR!4&(Mw``*WIIb{8CtrSkKO^?4!iR!eP+OQ(FL}F(B`u|XU)=_ly;2r*etT*f(D}9 z>tTCl;Yg`IrPqv{6T~|FFXB!cB~#Uws11L@D!56PZX&`mj(cK_Hgkj(3Dx-swOiCa-+&$`m)b0JJecZNyJnB4ub zaf{v6T;{1Y+RC0Wgvv+qGaQ&FF;g8WWrWooGm}brrq10t zzN?U%CVB<=@q5YiJgphlVC&8PrD1)?du?_KSDDw=Xa~j_rs^n)#_Wf8>5T;Atu7ad z3y7hx-q2uksf6bXBW_0+ksk{dVuVI_=<7skdvb!GcchJH_(Pq_xnO{^PB-`=p>q~f z^hjB8SVdrXM7y}W)RaN-1@kQT%7%!mmiEYFvr*~r5|IIJ>!x7LsTj7v1Hl3t_N(;c zW|FXKQ`p)0@=NOBmQ?QBz_8O*s>pHjt7IJFFloRGCP3gfWC{U+1;ttH-T>3Xl}V^K z_tUMs(dlyTD=oG%P}88n%A3Bm(aSA%N9v0SeQIhA)HT*<|UYBMh0zZn? zZC;=(`nC>Q1U*18FV9HiM)ualc1wm;c2PWQ=c5pG06T2VKn18Ux{6OhJ!recs01Qw zCUYu=tjM>kgf5uaUd0U>FJhIUpxP4CN8r0=F7s8@8W4RRpvi_2P{1ZBE6GQ=DlHcX z6%rQ8y_1uZ6h~};F^4gTixVeNudTvVXu%Pyo@+6gki-ApU^DFd(~2P>r~!6%LH|nK zbv%Zfv%`rILY!UchujbkS z)#H_BWE#}Yh@U?|0&^Esr||J3aBjtj&qI6*jKhZ*E{vJ=88UP>I_mvPQo#a5G##<;KGF98q5=P*e*IM0khWEYhVH86sqB<*;zr_d`I z!9k@>C_F1jZzm-n1#menpN^aCurnyLjC7wZQ*cX;oS@TMs$uO;wCr+O9TW; zg>BWu{ycKuj_NaU2JJV~718hUAEw1~B}_V~Y~uv-9I*s4CrzE}@H(FquFj~Q);wHb zi2Ns68Rtopr7ytx4NE{Sg1I%+p@339?E{lEqj{KziC^p$f2qHVPgN_6+9(&7PW7*o zEbf=`kyCiiOwV))WlxAfa`>q_6E`pKj?oFHMit0;dPJe=<_R8zV{u90VV<6uJYL7` zvrO`;BqBSyr#qIV=8e;nb$3&D{rsi=A)fb>j4Zaqip9>97m{tOgkeKQEF*-j43>?J zFYo1T5x0*!|Lct*ggMBgYuY*OBGoRZtei1$)KkT*uJ0ja7^|7a1|M%JsUXai@tW)+ zMaY`spt{C{U0b#L{xg=Ib8Z`m7GfB7XHtMh;PROV+N zfSA?+wNFMfGj*d(FM;IJy-JG)Jzx<=1 z&8s|pO=j|9ui3-!lS}*XYYCQ%N+O|rmwM~^C%4@esc~)9n_yG-jQCQj;C0p3(F@Jj zAHF-6=!sQFAG2Aifi^j8&yVcc;WC)m3Pw7+HGET4%h|@a_lR|U>mhbOf`9$mH1l;I z6u09r8FZekiFoK5dO@p_`ds!A0dfqodfR1lHUKlfIh#J8Ybz;xkHyCrt!3&`R63zx zVkU*9vsl1ImDCN!n3ejhvO2H_8RlnvtDv!Ap@f4=jg^&jzNBdzJ{s!#)Z0NR<14C) zx;RF$N_iZcfkpxP5=XgObw7FfMJoYJDV8x2tdc@CZ!a`giQ-H^!MHv%*WFOoawAxS zSSttXsP~v(w=}zm^yLWGCo8QL9Z24zqHQEh*HMzyV2m{fz*0_ZAx%Dnw{>S(;jR^d zuGxc8a0_99G_Nyvt39zOxdGL_yd3ad<5+9o8TgDrj86CHDX#Ev`;?z*NX|yw(B=bJ zs}cV+$N2UQ_j6|o6RCPe^%wFqZsCKL6#G}fL)RJ@fpef{TyPrP3h}Yxrksnh_0LMf z<39I%;p8J3Qmh>gT3ZbR2QK61o{=e_t+-xs)#T-ySSm|kn9Y^p`NX2(;?&q}U(tQm zg)q0a@`aTQBI*Kx`cN@sFdQ5!caQBpRn^?=pX?b>o82L7hF^~nNuAcTc-0(0!s5siqh z|FseJRyq3^o?~xlr5=li-mYcjI5tBNUvGwC4-EuhRh8p1(RQ6LS{h=+-+E1Ly8qJdvAOI-c+))%>A{aPrA~cK3b!QJd zo*#MQ6yGW@PQ|=nY87g;SoTqUOt|e*0I|$O-ijk}Y|b>VK&YbNQF5`#d_*y~nk9W+ z#U}lTI-W=EOij(Ky^3s)dvn%WWTT>p&3M97WuH;WiErg2oU%`S4R)d0RW7O3x^~tA z$p~Lduu!;z3-j@8mUSc>ftK3L+4ee$R`&RGtvd#Xi8){hXg8k06avXzw~53EHY_zH zjB;JcQ^8k3AJAI_iu-v=KF6fiR?!Z?Y-dyrG9m?a7zUE4wL2AlUsbX6;1uecI2`vM`Ip6lm<&%E+V(UTGW-3GN194W9 zWQWp@s(LsDG{r4jKKb~bujnpe^(PiWs}X#Illv{3(lueD{te_kje-pTe0<-@>DxOh ze%7}&rZup)-BZ()U7<(zzOTI4QAr$hMqF_d3gSK`MX5f3Kvxp@Xb$wnr*neVV&*Y0 z16kRk9$3XTE^g=C-VRyhVpG zP-1XIMbamqa8`jPn^fU&yB_do6wKAPCTRDd-92;Vd;L>MHx0zRb5Zr*4BgRicNEq z725$eZPD>5HGoJS;IyZ;o%Q0@L*;NOtUHEWL9RlXK@N3;(GwY<%NI<@juwYXRZ&T7 zc2HI|?ATxW9+)e*InWoc89kVkUypI5M?N2q(~ACW+BM*a!k*G)*c^K4P{KnM1vfwj zaRHUiZn$ik9T~&0jnb2(nR*k2u^4G)Hm}AxoN6y-MT7hD&bXqWk&<@#Sa|1losVRq_g|WYK7nCbcRP5MqR$p zdMIa5CZ}S;O>AjFo(zn?$$&T-?|gfG41b<7$1lm+Hn3md%J$9`(OUmD9(9xD7U86q z=u1Hl2jyp;dr2j-{jBkA-n?vbF@J~bWZ^p~gF_)F)Fmd$(ifxv;ha$e&Rhps6(z>- zaJQM1`k9}j7qtUhYJ-_c9$fNRwhn$PZZWKK6hxFcUeNc+Zo1FqAW);<*u+cvH=^6g zWNhP*BCJw>B+Eqvdds{G62gaqa&>gKMa+wl$r^AXW?{+kL75ol14;5+4}tZN%^ut1 z-xA#3kTWhp&`sn!>nkq!jV-<=jKxZsxCu=wduGpw!*P5RC%f!T?CK&@kofsk%3SP2 zIkabgLzXOlO}yZZ!y>V@Br(|W78~+i#BJl~UAG-PMcfW1%hp;O!x^DH_Of(<5r3bV5)Bfb^QWWIHSNniyGKB(l)06eVWvuZUf+1wZZiF zWt77wSA7TXV|VUOAb;!x`rX#D-F4Q9zyN^Cd!iKf@3v<6)TNgv?%0*()UyQSt?5I)P{+~ zwqHwb&<(kQJPt&gQH*bRN3-%#Q&M9O1r0RR%gez|9l=_%ljQT(9=FEVt?N{?y{z*B z)Hsmvm5guZ3rcpU!!5pmGzF6)ZFodFJh@ACc)0z@+J301_LO+#xlP&~+U<6|2x65J zfGG$^Jq1OQMgUHXt)FUAt3ij*-tAt=<=}3}t(Oz9piwbEV=u*S$NdZ*cq+5leO+Oy zkd14bB=~uzg5J`=wnXO@c%`+Ct2H%6Ci1a8p6wtGJ)M)FE;VV7c&)ZJ69BnPHp_;vjRkdlA~Q!uP6cFBI~nABs{K03==S97*42_ zSOqYcP0^38BjUMtE@4(qO?6!3G`bt5JB4{OZ2VKZA5uG#5i z+Dk=uXLL+c>2Ek@-kj}(&y>2=>8WG_FQ1*xy}d*vBIp8asGw;Q_@bn_AH*wL3-3ol zBps?_F_+R0Din-#gJ-*%fa0vTM--m)r1u0-+L-twnNzl)qnzoFNJ-UjD9hUJoKVuM z^Zcs?shcz5l9_paL{zY5L804NOv3Y+%u-S;IGlaNmzVMNSh`%9RomtPTn6*kBAvD} zlm#pi%q^PnQ~Avy*+?{&xb&-d#F#tI-Vw~ja#iMsI4T-}gm}*Fc^41a5mQ{dLl@Q@ zNx~XI1FPPAj>$H;ermH{CWvq<2&l9S&AFv4p7tp2WZ++ti_ypCBe((%W>MJ3;eN~L z=JO?L#u$_EQV{TcmIu1OrPq*hkAvB_Nx z1TMCM)WFws2@XracH{6$MnULFAH0#Rm-orFPv0D__W%q&`e>R7+i<9#8|j`N^Wo6d z`z_@=2sI9tB+Q*}rfD-4thVT;;*;to5qk5%F;yikE8!LYJl}fsAQF>1MBlwolKmml zAtrkEEyCk0tYDOqwy6wZhja6gktIHk0qEiB%JtasgJbB@s1$hlBD|>GEX2nNXF4$! z5+Tc{UMff8SKDuX(ya5BYati_t@`4YhB0Gt^VgD{ zVl3L*aA4V3yNMT}$+e(theA~}TiV1mBo*yapL#1mc)RO9O$7^KM!FPM8v8i6$Gr65 zFAk0nzv2Tg-=L!a5or=s8HeH_ht7hw?7VIKZeV??Shv)715@JsVPK|zH*ih+an%21 zU^0@lcLTQwGc)_v+qK1Aw%wbw1)$?e_lpjYUDdT)*({fdx)t&cx*VME9qe1LBM*#e zU(;(#t)x86X0p08sfm%*O%m1rjQtU1BIK>fFJ;#eNuRJGfe}k@!$%lv9A1Trv%_p0YRl;QeZAMvJPT>36@ftQ3|$@-S0dF_4e- zvvis#zW-4#z=d?skCbAhYsC9>|k{~N3ccxkm6zp1}MR9nevOJ>^i8R*<$ z@hQlhg1e5`29eU|!Ig?TL20N|WMlp*KN?+of&{5W=%3>LX|;z=I_#%>hE`4&4KVWK zI-tsKQ71N1a>i9g=pJ&RExtKUl-H3RZxP*mMz%x7#En1Xk6$&>i@8M5h*Tp_<^#~Y zVplXJrr9%+6D-|ew|5)w_Ag5_5(e0mDl0n2We#RcoSl4X-SJfn?0Mg&(uwjkyLt8O zXK)(Z92Tl;*;_xuqh2zf2^RdaadNW74wy57bJsL~Sy~?T})3Z0DuTPWD z(~-7OiDJ4;MVe;5%x*~SND*n~tMYzm18yD)sT^vMoQBzR7>J!cL1v`4Y~d7Bx2$DW zFZ8>Uqsk48|8jDEf-olB=MAPyIyi+w#qZZh6dNu5FFu=xcXghGjFFExn^Kv&}N_?F`PU5K>aLOZ~@_g`4#m6Lgegc&EDLc zh!D>p<{%cL+jb-kEwDyYykHjJLKh$}YO<&r&t1-GD4p2zBc^pN1;%lX;5Pu)!QJx( zM>=fGm=wq7zF;=efyK}JOY0B{DG&$)(pxATP@TNZ35C7}ps>NR}#&m8oa5VIF z{pDhF!!N&FT&Rnj*8hfJkXtU+jBsxW1j~+KF6oLUZYgdtA^FkOWs@wv0a-&gR)n6j zk#6R7*FED7IzTTViQ=aq1p18eD1_P)lpm3xXAWOJ#UM`50om^^*2}+!LxBVUCOrWF zxc7ymsFT@m1$S+Ahix`^?}^IE8s)U42tye`mQiHL6hjM>IH;x3OGXmD4-@DsB+!V? z7H>}tp1uTNmLsGxB5q^vQvovI@AdcGm|`ITxnuo2W@ zq^KlftXmV1Cbn?G^01MSdi{Ecvshn;K5RS^#}AX-z;dF&v9M^TZU(4=LwL)qO$0-H z{8loQm=#i);wrFQz+}NjC;ZWa>n3%S+He?>3df_-ZUEck{>rB9aid)3tg`siOh}+J z!}r70A~O8h3<55wpdrGuQKLaTfHoKXHc#OU?b;;8C7u)iz}4e6t%cyT=v5KFrS*5< zwu~QSWcpCFCEi78HYK0x(G(LsYn&rBD z$$iGElAeW?@^B~Keyox;8Lmrzlxj6HiuQZ@@jP(0csC?Yk1m3dABY{igsj5)Z0|o{ zyKokvuODo(nB3E&oGPKM{Q`L&zIP4o@)l<6@JJui9SBsT{kjjMSLo)gF5RXT3R245$%~ zAUaROy9Dgf`(7?i11uNMd7lE;4yt#CJKE^^ofUlcT+YVtNr_wn!EAI;ypJV+a8amp zK~Z#^SF<0i^-SIIcVaPZ?wyEJg?B8%wKRvb8_?U~w`zewvS^npsMNa)qpW*EV{*oC zAGVYA^jXY6Uom8z5}5@4K;L!fRngK}9qQ|@0n_DooxYF9rFLkY{l=xYcGqMboQK)E+FcO?gGb;Bf>AQJDzw9B0_d(X@GKf_Xt~r^#7F`P2RW$809`=4^HPz~iT< zJ27Hy)j-%TiLji_8s|7`C(-lc;PKq@S#GiDAXl(1Vi>8Qsnp2>1lzcfpS~GQfb{w9 zgqeY4DIy$VMa!alb~X12K2Bu$U`u8RYyJiMeDaI29|&E!;cuWS(f6Pe2QRC%%0$mZ z=i5nEXq^p`8sFnC%I({AruYIaA#SMC1i_=fNf0t+sBM_hTB0AnIhW@GU|-^;Vspm}bkBLc_5q4drV5gV{Ep zQ&lJ|(ss`!C2mj-zb$klz!Tx^H0(z5wr!V6ShBF4Y1DOmdEILgCU%9wLN2R zIvnjC{Mhq(y*eH4BJE8?3c@6*>y)lA#+gd=?zp*bBoh$Id`bA#ZSTc7IxtNPyhMC{ zp3p6rc3}!3Zo*5zOU9?Cfjce~XNtRgZW3pno=M7lC8ScrrMR=Nn!%xXB~0L{q{C0> z2)uV&e;#j}_{NW2a#Jw9p*2M4sm5-$>9R-OrfW)+6#70w@RLNQFs}SLxIW0>5p2Md z8%jlUPdqoJ&Wt_`T)*b={5QCvG9zgkV~(C{O7)o(MvJQA7hno3ggLmoDyKXn1yhrw z?-q=KsT|3sV5lKAMi^vRBM8P>c7(^zY}C2qQMP>vTI~S4A5dB%2L{F0lpI;qk9lx? zV!6jbH|S1Or_^6ha7uvJ9{_px@u)c?9uT-TqvKUEJj`j-zPF0 zA}UY1tDR?q86+)!_<_-{*bn(Y2o1MyTD~w>T92twpo|t|cBfG#nd}uVUu{u+CteF~ zJX9h4GAGPuS)RD>z}6dYGgR@7&12$IYoJ>zzHn= zq@n-mQu3kOT&rtA#@7-_t*=CgA7kn%tF#z~k98eBpe~_w~>77Xz^b!>Cn|kJON9Ly2C@CzP zNu;E2sn*i0c6U3eF4by5*KKp67NLuZsIxxaBha#BQ~qj0RHBXteoPv5c^c8MJL#b{ z9ax<&RL;nvpxmp1QJrFhvWR99hdlT+?v&8ZNZ3Nx7q_^iI*SHa->GpT1fhijNP@MU zuloF~>k|LcI$&~A|1*f4^W#~EgPAUiMN!~dR#?5RN*sU1vEfZq=7Y~1 zpa{tYaf`6R!V9)`^!n4~e1FC5V5iS%0MAepHoY}?Ga@XqRJvfwa<-1tzfDS-#RQ+_!d(V1MIkS_U^iUS?_D3qt z?BVGPDJTFy@V=qHub>=1J6Rc6>zi3R&?)`1OKWRw3Pb?9{`f^sM)V^TCe-`5kK$rN z3h#U0UmL`GE)O83b(j9WgR~V>cX+?2>ix9=B~Za)0RSIn#D(~kT+>g}CS)i*>FbgjXL%P*kYPi(!A&sHW!*n@e9z|gY!U{>)0duUvUsR~ z9JxIZe$Rx%=#fehGj)BT<{uqI?EQ+~HHz*6w83crV2TAA4gKqHkpC+NgD=Jqz@!&o zLJX1|LlTOi?w{PvwE6P1HgRrhGCw|{>&2jFVnTzO_UIFSHIZD1Fj=gnXf(#54wjT+I6PukDeSEByK1{KN` z<09*GD%h+N;G-Pg9gS}ikggLtFAy^yqh4UK5*0?*S9E%T*{~o+0C{DoSh)s!Xao$T zzR9eBn%N>Na54~OC=iiQxkf&GqN!jCWHyjJJQhI>v_D|z{bkYx6YcQp^iDq5qJ2=L zD#7$+214u^?ZSYs(?odhe3%4$vnnfAbKzSQ7l zYs-f(4lwL$_Yh%et!Zjv*7jEY0YPfeu0p5*Pz8mDT5zRvl$Mn8WMCspT|Q@To&G z?FG&Z8ERCb+~eb8nIZ=}yXwY9m$z5Av^2Wr5p3YLfW_fqS(}a5isLLt?R&1MP^|@S zE-q@+@Iv+|r(4L+keIvO0RwwiS(mO_3r>U_92`G%Uy5YAD|otT__Ne?bV`(7B?=UZ zii$WnISbj}PL}9ag?|D~`|=#!L}_Yj3K^wdPh<(|c=deJi$kMaEW_WEw`5U{wo_F8>tw>p;JYd#CjD9^gLwq3-Vx{J*RF57d8?$M_%Q{r%egyS!i2|L5ZU zAM*Z20Rba}{O9NV-_Pu?R_FI?_xB`(oaFE1pEd1&Z~#D^pUitX?6(T{pXi@;vwxu^ zD1Sr$s-peL{#mj37khyAH}h-$-BFtoo%{-5OEL+Y0u|JmyNc;Ct2(?