# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * Copyright (c) 2017 Shai Seger * # * * # * 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 Path import Path.Base.Util as PathUtil import Path.Dressup.Utils as PathDressup import PathScripts.PathUtils as PathUtils import Path.Main.Job as PathJob import PathGui import PathSimulator import math import os from FreeCAD import Vector, Base # lazily loaded modules from lazy_loader.lazy_loader import LazyLoader Mesh = LazyLoader("Mesh", globals(), "Mesh") Part = LazyLoader("Part", globals(), "Part") if FreeCAD.GuiUp: import FreeCADGui from PySide import QtGui, QtCore _filePath = os.path.dirname(os.path.abspath(__file__)) class CAMSimTaskUi: def __init__(self, parent): # this will create a Qt widget from our ui file self.form = FreeCADGui.PySideUic.loadUi(":/panels/TaskPathSimulator.ui") self.parent = parent def accept(self): self.parent.accept() FreeCADGui.Control.closeDialog() def reject(self): self.parent.cancel() FreeCADGui.Control.closeDialog() def TSError(msg): QtGui.QMessageBox.information(None, "Path Simulation", msg) class PathSimulation: def __init__(self): self.debug = False self.timer = QtCore.QTimer() QtCore.QObject.connect(self.timer, QtCore.SIGNAL("timeout()"), self.PerformCut) self.stdrot = FreeCAD.Rotation(Vector(0, 0, 1), 0) self.iprogress = 0 self.numCommands = 0 self.simperiod = 20 self.accuracy = 0.1 self.resetSimulation = False self.jobs = [] def Connect(self, but, sig): QtCore.QObject.connect(but, QtCore.SIGNAL("clicked()"), sig) def UpdateProgress(self): if self.numCommands > 0: self.taskForm.form.progressBar.setValue(self.iprogress * 100 / self.numCommands) def Activate(self): self.initdone = False self.taskForm = CAMSimTaskUi(self) form = self.taskForm.form self.Connect(form.toolButtonStop, self.SimStop) self.Connect(form.toolButtonPlay, self.SimPlay) self.Connect(form.toolButtonPause, self.SimPause) self.Connect(form.toolButtonStep, self.SimStep) self.Connect(form.toolButtonFF, self.SimFF) form.sliderSpeed.valueChanged.connect(self.onSpeedBarChange) self.onSpeedBarChange() form.sliderAccuracy.valueChanged.connect(self.onAccuracyBarChange) self.onAccuracyBarChange() self._populateJobSelection(form) form.comboJobs.currentIndexChanged.connect(self.onJobChange) self.onJobChange() FreeCADGui.Control.showDialog(self.taskForm) self.disableAnim = False self.isVoxel = True self.firstDrill = True self.voxSim = PathSimulator.PathSim() self.SimulateMill() self.initdone = True def _populateJobSelection(self, form): # Make Job selection combobox setJobIdx = 0 jobName = "" jIdx = 0 # Get list of Job objects in active document jobList = FreeCAD.ActiveDocument.findObjects("Path::FeaturePython", "Job.*") jCnt = len(jobList) # Check if user has selected a specific job for simulation guiSelection = FreeCADGui.Selection.getSelectionEx() if guiSelection: # Identify job selected by user sel = guiSelection[0] if hasattr(sel.Object, "Proxy") and isinstance(sel.Object.Proxy, PathJob.ObjectJob): jobName = sel.Object.Name FreeCADGui.Selection.clearSelection() # populate the job selection combobox form.comboJobs.blockSignals(True) form.comboJobs.clear() form.comboJobs.blockSignals(False) for j in jobList: form.comboJobs.addItem(j.ViewObject.Icon, j.Label) self.jobs.append(j) if j.Name == jobName or jCnt == 1: setJobIdx = jIdx jIdx += 1 # Preselect GUI-selected job in the combobox if jobName or jCnt == 1: form.comboJobs.setCurrentIndex(setJobIdx) else: form.comboJobs.setCurrentIndex(0) def SetupSimulation(self): form = self.taskForm.form self.activeOps = [] self.numCommands = 0 self.ioperation = 0 for i in range(form.listOperations.count()): if form.listOperations.item(i).checkState() == QtCore.Qt.CheckState.Checked: self.firstDrill = True self.activeOps.append(self.operations[i]) self.numCommands += len(self.operations[i].Path.Commands) self.stock = self.job.Stock.Shape if self.isVoxel: maxlen = self.stock.BoundBox.XLength if maxlen < self.stock.BoundBox.YLength: maxlen = self.stock.BoundBox.YLength self.resolution = 0.01 * self.accuracy * maxlen self.voxSim.BeginSimulation(self.stock, self.resolution) ( self.cutMaterial.Mesh, self.cutMaterialIn.Mesh, ) = self.voxSim.GetResultMesh() else: self.cutMaterial.Shape = self.stock self.busy = False self.tool = None for i in range(len(self.activeOps)): self.SetupOperation(0) if self.tool is not None: break self.iprogress = 0 self.UpdateProgress() def SetupOperation(self, itool): self.operation = self.activeOps[itool] try: self.tool = PathDressup.toolController(self.operation).Tool except Exception: self.tool = None if self.tool is not None: self.cutTool.Shape = self.tool.Shape if not self.cutTool.Shape.isValid() or self.cutTool.Shape.isNull(): self.EndSimulation() raise RuntimeError( "Path Simulation: Error in tool geometry - {}".format(self.tool.Name) ) self.cutTool.ViewObject.show() self.voxSim.SetToolShape(self.cutTool.Shape, 0.05 * self.accuracy) self.icmd = 0 self.curpos = FreeCAD.Placement(self.initialPos, self.stdrot) self.cutTool.Placement = self.curpos self.opCommands = PathUtils.getPathWithPlacement(self.operation).Commands def SimulateMill(self): self.job = self.jobs[self.taskForm.form.comboJobs.currentIndex()] self.busy = False self.height = 10 self.skipStep = False self.initialPos = Vector(0, 0, self.job.Stock.Shape.BoundBox.ZMax) # Add cut tool self.cutTool = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", "CutTool") self.cutTool.ViewObject.Proxy = 0 self.cutTool.ViewObject.hide() # Add cut material if self.isVoxel: self.cutMaterial = FreeCAD.ActiveDocument.addObject( "Mesh::FeaturePython", "CutMaterial" ) self.cutMaterialIn = FreeCAD.ActiveDocument.addObject( "Mesh::FeaturePython", "CutMaterialIn" ) self.cutMaterialIn.ViewObject.Proxy = 0 self.cutMaterialIn.ViewObject.show() self.cutMaterialIn.ViewObject.ShapeColor = (1.0, 0.85, 0.45, 0.0) else: self.cutMaterial = FreeCAD.ActiveDocument.addObject( "Part::FeaturePython", "CutMaterial" ) self.cutMaterial.Shape = self.job.Stock.Shape self.cutMaterial.ViewObject.Proxy = 0 self.cutMaterial.ViewObject.show() self.cutMaterial.ViewObject.ShapeColor = (0.5, 0.25, 0.25, 0.0) # Add cut path solid for debug if self.debug: self.cutSolid = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", "CutDebug") self.cutSolid.ViewObject.Proxy = 0 self.cutSolid.ViewObject.hide() self.SetupSimulation() self.resetSimulation = True FreeCAD.ActiveDocument.recompute() def PerformCutBoolean(self): if self.resetSimulation: self.resetSimulation = False self.SetupSimulation() if self.busy: return self.busy = True cmd = self.opCommands[self.icmd] pathSolid = None if cmd.Name in ("G0", "G00"): self.firstDrill = True self.curpos = self.RapidMove(cmd, self.curpos) if cmd.Name in ("G1", "G01", "G2", "G02", "G3", "G03"): self.firstDrill = True if self.skipStep: self.curpos = self.RapidMove(cmd, self.curpos) else: (pathSolid, self.curpos) = self.GetPathSolid(self.tool, cmd, self.curpos) if cmd.Name == "G80": self.firstDrill = True if cmd.Name in ("G73", "G81", "G82", "G83"): if self.firstDrill: extendcommand = Path.Command("G0", {"Z": cmd.r}) self.curpos = self.RapidMove(extendcommand, self.curpos) self.firstDrill = False extendcommand = Path.Command("G0", {"X": cmd.x, "Y": cmd.y, "Z": cmd.r}) self.curpos = self.RapidMove(extendcommand, self.curpos) extendcommand = Path.Command("G1", {"X": cmd.x, "Y": cmd.y, "Z": cmd.z}) self.curpos = self.RapidMove(extendcommand, self.curpos) extendcommand = Path.Command("G1", {"X": cmd.x, "Y": cmd.y, "Z": cmd.r}) self.curpos = self.RapidMove(extendcommand, self.curpos) self.skipStep = False if pathSolid is not None: if self.debug: self.cutSolid.Shape = pathSolid newStock = self.stock.cut([pathSolid], 1e-3) try: if newStock.isValid(): self.stock = newStock.removeSplitter() except Exception: if self.debug: print("invalid cut at cmd #{}".format(self.icmd)) if not self.disableAnim: self.cutTool.Placement = FreeCAD.Placement(self.curpos, self.stdrot) self.icmd += 1 self.iprogress += 1 self.UpdateProgress() if self.icmd >= len(self.opCommands): self.ioperation += 1 if self.ioperation >= len(self.activeOps): self.EndSimulation() return else: self.SetupOperation(self.ioperation) if not self.disableAnim: self.cutMaterial.Shape = self.stock self.busy = False def PerformCutVoxel(self): if self.resetSimulation: self.resetSimulation = False self.SetupSimulation() if self.busy: return self.busy = True cmd = self.opCommands[self.icmd] # for cmd in job.Path.Commands: if cmd.Name in ("G0", "G00", "G1", "G01", "G2", "G02", "G3", "G03"): index = self.icmd if cmd.Name in ("G2", "G02", "G3", "G03"): while cmd.z is None: index -= 1 if index < 0: cmd.z = 0 break cmd.z = self.opCommands[index].z self.firstDrill = True if cmd.Name in ("G2", "G02", "G3", "G03") and (cmd.k or 0) == 0: cx = self.curpos.Base.x + (cmd.i or 0) cy = self.curpos.Base.y + (cmd.j or 0) a0 = math.atan2(self.curpos.Base.y - cy, self.curpos.Base.x - cx) a1 = math.atan2(cmd.y - cy, cmd.x - cx) da = a1 - a0 if cmd.Name in ("G3", "G03"): da = da % (2 * math.pi) else: da = -((-da) % (2 * math.pi)) r = math.sqrt((cmd.i or 0) ** 2 + (cmd.j or 0) ** 2) n = math.ceil(math.sqrt(r / self.resolution * da * da)) da = da / n dz = (cmd.z - self.curpos.Base.z) / n cmd.Name = "G1" for i in range(n): a0 += da cmd.x = cx + r * math.cos(a0) cmd.y = cy + r * math.sin(a0) cmd.z = self.curpos.Base.z + dz self.curpos = self.voxSim.ApplyCommand(self.curpos, cmd) else: self.curpos = self.voxSim.ApplyCommand(self.curpos, cmd) if not self.disableAnim: self.cutTool.Placement = self.curpos ( self.cutMaterial.Mesh, self.cutMaterialIn.Mesh, ) = self.voxSim.GetResultMesh() if cmd.Name == "G80": self.firstDrill = True if cmd.Name in ("G73", "G81", "G82", "G83"): extendcommands = [] if self.firstDrill: extendcommands.append(Path.Command("G0", {"Z": cmd.r})) self.firstDrill = False extendcommands.append(Path.Command("G0", {"X": cmd.x, "Y": cmd.y, "Z": cmd.r})) extendcommands.append(Path.Command("G1", {"X": cmd.x, "Y": cmd.y, "Z": cmd.z})) extendcommands.append(Path.Command("G1", {"X": cmd.x, "Y": cmd.y, "Z": cmd.r})) for ecmd in extendcommands: self.curpos = self.voxSim.ApplyCommand(self.curpos, ecmd) if not self.disableAnim: self.cutTool.Placement = self.curpos ( self.cutMaterial.Mesh, self.cutMaterialIn.Mesh, ) = self.voxSim.GetResultMesh() self.icmd += 1 self.iprogress += 1 self.UpdateProgress() if self.icmd >= len(self.opCommands): self.ioperation += 1 if self.ioperation >= len(self.activeOps): self.EndSimulation() return else: self.SetupOperation(self.ioperation) self.busy = False def PerformCut(self): if self.isVoxel: self.PerformCutVoxel() else: self.PerformCutBoolean() def RapidMove(self, cmd, curpos): path = Path.Geom.edgeForCmd(cmd, curpos) # hack to overcome occ bug if path is None: return curpos return path.valueAt(path.LastParameter) # get a solid representation of a tool going along path def GetPathSolid(self, tool, cmd, pos): toolPath = Path.Geom.edgeForCmd(cmd, pos) startDir = toolPath.tangentAt(0) startDir[2] = 0.0 endPos = toolPath.valueAt(toolPath.LastParameter) endDir = toolPath.tangentAt(toolPath.LastParameter) try: startDir.normalize() endDir.normalize() except Exception: return (None, endPos) # hack to overcome occ bugs rad = float(tool.Diameter) / 2.0 - 0.001 * pos[2] if type(toolPath.Curve) is Part.Circle and toolPath.Curve.Radius <= rad: rad = toolPath.Curve.Radius - 0.01 * (pos[2] + 1) return (None, endPos) # create the path shell toolProf = self.CreateToolProfile(tool, startDir, pos, rad) rotmat = Base.Matrix() rotmat.move(pos.negative()) rotmat.rotateZ(math.pi) rotmat.move(pos) mirroredProf = toolProf.transformGeometry(rotmat) fullProf = Part.Wire([toolProf, mirroredProf]) pathWire = Part.Wire(toolPath) try: pathShell = pathWire.makePipeShell([fullProf], False, True) except Exception: if self.debug: Part.show(pathWire) Part.show(fullProf) return (None, endPos) # create the start cup startCup = toolProf.revolve(pos, Vector(0, 0, 1), -180) # create the end cup endProf = self.CreateToolProfile(tool, endDir, endPos, rad) endCup = endProf.revolve(endPos, Vector(0, 0, 1), 180) fullShell = Part.makeShell(startCup.Faces + pathShell.Faces + endCup.Faces) return (Part.makeSolid(fullShell).removeSplitter(), endPos) # create radial profile of the tool (90 degrees to the direction of the path) def CreateToolProfile(self, tool, dir, pos, rad): type = tool.ToolType xf = dir[0] * rad yf = dir[1] * rad xp = pos[0] yp = pos[1] zp = pos[2] h = tool.CuttingEdgeHeight if h <= 0.0: # set default if user fails to avoid freeze h = 1.0 Path.Log.error("SET Tool Length") # common to all tools vTR = Vector(xp + yf, yp - xf, zp + h) vTC = Vector(xp, yp, zp + h) vBC = Vector(xp, yp, zp) lT = Part.makeLine(vTR, vTC) res = None if type == "ChamferMill": ang = 90 - tool.CuttingEdgeAngle / 2.0 if ang > 80: ang = 80 if ang < 0: ang = 0 h1 = math.tan(ang * math.pi / 180) * rad if h1 > (h - 0.1): h1 = h - 0.1 vBR = Vector(xp + yf, yp - xf, zp + h1) lR = Part.makeLine(vBR, vTR) lB = Part.makeLine(vBC, vBR) res = Part.Wire([lB, lR, lT]) elif type == "BallEndMill": h1 = rad if h1 >= h: h1 = h - 0.1 vBR = Vector(xp + yf, yp - xf, zp + h1) r2 = h1 / 2.0 h2 = rad - math.sqrt(rad * rad - r2 * r2) vBCR = Vector(xp + yf / 2.0, yp - xf / 2.0, zp + h2) cB = Part.Edge(Part.Arc(vBC, vBCR, vBR)) lR = Part.makeLine(vBR, vTR) res = Part.Wire([cB, lR, lT]) else: # default: assume type == "EndMill" vBR = Vector(xp + yf, yp - xf, zp) lR = Part.makeLine(vBR, vTR) lB = Part.makeLine(vBC, vBR) res = Part.Wire([lB, lR, lT]) return res def onJobChange(self): form = self.taskForm.form j = self.jobs[form.comboJobs.currentIndex()] self.job = j form.listOperations.clear() self.operations = [] for op in j.Operations.OutList: if PathUtil.opProperty(op, "Active"): listItem = QtGui.QListWidgetItem(op.ViewObject.Icon, op.Label) listItem.setFlags(listItem.flags() | QtCore.Qt.ItemIsUserCheckable) listItem.setCheckState(QtCore.Qt.CheckState.Checked) self.operations.append(op) form.listOperations.addItem(listItem) if self.initdone: self.SetupSimulation() def onSpeedBarChange(self): form = self.taskForm.form self.simperiod = 1000 / form.sliderSpeed.value() form.labelGPerSec.setText(str(form.sliderSpeed.value()) + " G/s") self.timer.setInterval(self.simperiod) def onAccuracyBarChange(self): form = self.taskForm.form self.accuracy = 1.1 - 0.1 * form.sliderAccuracy.value() form.labelAccuracy.setText(str(round(self.accuracy, 1)) + "%") def GuiBusy(self, isBusy): form = self.taskForm.form form.toolButtonPlay.setEnabled(not isBusy) form.toolButtonPause.setEnabled(isBusy) form.toolButtonStep.setEnabled(not isBusy) form.toolButtonFF.setEnabled(not isBusy) def EndSimulation(self): self.UpdateProgress() self.timer.stop() self.GuiBusy(False) self.ViewShape() self.resetSimulation = True def SimStop(self): self.cutTool.ViewObject.hide() self.iprogress = 0 self.EndSimulation() def InvalidOperation(self): if len(self.activeOps) == 0: return True if self.tool is None: TSError("No tool assigned for the operation") return True return False def SimFF(self): if self.InvalidOperation(): return self.GuiBusy(True) self.timer.start(1) self.disableAnim = True def SimStep(self): if self.InvalidOperation(): return self.disableAnim = False self.PerformCut() def SimPlay(self): if self.InvalidOperation(): return self.disableAnim = False self.GuiBusy(True) self.timer.start(self.simperiod) def ViewShape(self): if self.isVoxel: ( self.cutMaterial.Mesh, self.cutMaterialIn.Mesh, ) = self.voxSim.GetResultMesh() else: self.cutMaterial.Shape = self.stock def SimPause(self): if self.disableAnim: self.ViewShape() self.GuiBusy(False) self.timer.stop() def RemoveTool(self): if self.cutTool is None: return FreeCAD.ActiveDocument.removeObject(self.cutTool.Name) self.cutTool = None def RemoveInnerMaterial(self): if self.cutMaterialIn is not None: if self.isVoxel and self.cutMaterial is not None: mesh = Mesh.Mesh() mesh.addMesh(self.cutMaterial.Mesh) mesh.addMesh(self.cutMaterialIn.Mesh) self.cutMaterial.Mesh = mesh FreeCAD.ActiveDocument.removeObject(self.cutMaterialIn.Name) self.cutMaterialIn = None def RemoveMaterial(self): if self.cutMaterial is not None: FreeCAD.ActiveDocument.removeObject(self.cutMaterial.Name) self.cutMaterial = None self.RemoveInnerMaterial() def accept(self): self.EndSimulation() self.RemoveInnerMaterial() self.RemoveTool() def cancel(self): self.EndSimulation() self.RemoveTool() self.RemoveMaterial() class CommandPathSimulate: def GetResources(self): return { "Pixmap": "CAM_Simulator", "MenuText": QtCore.QT_TRANSLATE_NOOP("CAM_Simulator", "Legacy CAM Simulator"), "Accel": "P, M", "ToolTip": QtCore.QT_TRANSLATE_NOOP("CAM_Simulator", "Simulates G-code on stock"), } def IsActive(self): if FreeCAD.ActiveDocument is not None: for o in FreeCAD.ActiveDocument.Objects: if o.Name[:3] == "Job": return True return False def Activated(self): pathSimulation = PathSimulation() pathSimulation.Activate() if FreeCAD.GuiUp: # register the FreeCAD command FreeCADGui.addCommand("CAM_Simulator", CommandPathSimulate()) FreeCAD.Console.PrintLog("Loading PathSimulator Gui… done\n")