freecad-cam/Mod/CAM/Path/Main/Gui/Simulator.py
2026-02-01 01:59:24 +01:00

634 lines
23 KiB
Python

# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2017 Shai Seger <shaise at gmail> *
# * *
# * 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
# Pre-select 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"]:
self.firstDrill = True
self.curpos = self.RapidMove(cmd, self.curpos)
if cmd.Name in ["G1", "G2", "G3"]:
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 in ["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", "G1", "G2", "G3"]:
self.firstDrill = True
if cmd.Name in ["G2", "G3"] 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 == "G3":
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 in ["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", "CAM Simulator"),
"Accel": "P, M",
"ToolTip": QtCore.QT_TRANSLATE_NOOP("CAM_Simulator", "Simulate 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")