''' This module provides a UI element which can display gcode on a Kivy canvas element. It also provides panning and zooming features. It was not originally written as a stand alone module which might create some weirdness. ''' from kivy.uix.floatlayout import FloatLayout from kivy.properties import NumericProperty, ObjectProperty from kivy.graphics import Color, Ellipse, Line, Point from kivy.clock import Clock from DataStructures.makesmithInitFuncs import MakesmithInitFuncs from UIElements.positionIndicator import PositionIndicator from UIElements.viewMenu import ViewMenu from kivy.graphics.transformation import Matrix from kivy.core.window import Window from UIElements.modernMenu import ModernMenu from kivy.metrics import dp from kivy.graphics.texture import Texture from kivy.graphics import Rectangle import re import math import global_variables import sys class GcodeCanvas(FloatLayout, MakesmithInitFuncs): canvasScaleFactor = 1 #scale from mm to pixels INCHES = 25.4 MILLIMETERS = 1 xPosition = 0 yPosition = 0 zPosition = 0 lineNumber = 0 #the line number currently being processed absoluteFlag = 0 prependString = "G01 " maxNumberOfLinesToRead = 300000 def initialize(self): self.targetIndicator.color = self.data.targetInicatorColor self.positionIndicator.color = self.data.posIndicatorColor self.drawWorkspace() if self.data.config.getboolean('Ground Control Settings', 'centerCanvasOnResize'): Window.bind(on_resize = self.centerCanvas) self.data.bind(gcode = self.updateGcode) self.data.bind(backgroundRedraw = self.reloadGcode) self.data.bind(gcodeShift = self.reloadGcode) #No need to reload if the origin is changed, just clear and redraw self.data.bind(gcodeFile = self.centerCanvasAndReloadGcode) global_variables._keyboard = Window.request_keyboard(self._keyboard_closed, self) global_variables._keyboard.bind(on_key_down=self._on_keyboard_down) self.centerCanvasAndReloadGcode() def addPoint(self, x, y): ''' Add a point to the line currently being plotted ''' self.line.points.extend((x,y)) def _keyboard_closed(self): ''' If the window looses focus. ''' global_variables._keyboard.unbind(on_key_down=self._on_keyboard_down) global_variables._keyboard = None def _on_keyboard_down(self, keyboard, keycode, text, modifiers): ''' Called when a button is pressed. ''' scaleFactor = .03 anchor = (0,0) # print 'gcodeCanvas',keycode, text, modifiers # handy for exploring keyboard input if keycode[1] == self.data.config.get('Ground Control Settings', 'zoomIn'): mat = Matrix().scale(1-scaleFactor, 1-scaleFactor, 1) self.scatterInstance.apply_transform(mat, anchor) return True # we handled this key - don't pass to other callbacks elif keycode[1] == self.data.config.get('Ground Control Settings', 'zoomOut'): mat = Matrix().scale(1+scaleFactor, 1+scaleFactor, 1) self.scatterInstance.apply_transform(mat, anchor) return True # we handled this key - don't pass to other callbacks elif keycode[1] == self.data.config.get('Ground Control Settings', 'runKey'): self.parent.startRun() # "click" the 'Run' button return True # we handled this key - don't pass to other callbacks elif keycode[1] == self.data.config.get('Ground Control Settings', 'pauseKey'): self.parent.pause() # "click" the 'Pause' button return True # we handled this key - don't pass to other callbacks elif keycode[1] == self.data.config.get('Ground Control Settings', 'stopKey'): self.parent.stopRun() # "click" the 'Stop' button return True # we handled this key - don't pass to other callbacks # elif keycode[1] == self.data.config.get('Ground Control Settings', 'fakeServo_Off'): # if self.data.config.get('Ground Control Settings', 'fsModeAllowed') == "Allowed": # if 'ctrl' in modifiers or 'alt' in modifiers or 'meta' in modifiers: # self.data.gcode_queue.put("B99 ON \n") # else: # self.data.gcode_queue.put("B99 OFF \n") # return True # we handled this key - don't pass to other callbacks # else: # return False # we didn't handle this key - let next callback handle it elif keycode[1] == self.data.config.get('Ground Control Settings', 'fakeServo_Off'): if 'ctrl' in modifiers or 'alt' in modifiers or 'meta' in modifiers: if self.data.config.get('Ground Control Settings', 'fsModeAllowed') == "Allowed": self.data.gcode_queue.put("B99 ON \n") else: self.data.gcode_queue.put("B99 OFF \n") return True # we handled this key - don't pass to other callbacks else: return False # we didn't handle this key - let next callback handle it def isClose(self, a, b): return abs(a-b) <= self.data.tolerance def centerCanvasAndReloadGcode(self, *args): self.centerCanvas() self.reloadGcode() def reloadGcode(self, *args): ''' This reloads the gcode from the hard drive in case it has been updated. ''' filename = self.data.gcodeFile if filename is "": #Blank the g-code if we're loading "nothing" self.data.gcode = " " self.data.gcode = "" return try: filterfile = open(filename, 'r') rawfilters = filterfile.read() filtersparsed = re.sub(r'\(([^)]*)\)','\n',rawfilters) #replace mach3 style gcode comments with newline filtersparsed = re.sub(r';([^\n]*)\n','\n',filtersparsed) #replace standard ; initiated gcode comments with newline filtersparsed = re.sub(r'\n\n','\n',filtersparsed) #removes blank lines filtersparsed = re.sub(r'([0-9])([GXYZIJFTM]) *', '\\1 \\2',filtersparsed) #put spaces between gcodes filtersparsed = re.sub(r' +',' ',filtersparsed) #condense space runs if self.data.config.getint('Advanced Settings','truncate'): digits = self.data.config.get('Advanced Settings','digits') filtersparsed = re.sub(r'([+-]?\d*\.\d{1,'+digits+'})(\d*)',r'\g<1>',filtersparsed) #truncates all long floats to 4 decimal places, leaves shorter floats filtersparsed = re.split('\n', filtersparsed) #splits the gcode into elements to be added to the list filtersparsed = [x + ' ' for x in filtersparsed] #adds a space to the end of each line filtersparsed = [x.lstrip() for x in filtersparsed] filtersparsed = [x.replace('X ','X') for x in filtersparsed] filtersparsed = [x.replace('Y ','Y') for x in filtersparsed] filtersparsed = [x.replace('Z ','Z') for x in filtersparsed] filtersparsed = [x.replace('I ','I') for x in filtersparsed] filtersparsed = [x.replace('J ','J') for x in filtersparsed] filtersparsed = [x.replace('F ','F') for x in filtersparsed] self.data.gcode = "[]" self.data.gcode = filtersparsed filterfile.close() #closes the filter save file #Find gcode indicies of z moves self.data.zMoves = [0] zList = [] for index, line in enumerate(self.data.gcode): z = re.search("Z(?=.)([+-]?([0-9]*)(\.([0-9]+))?)",line) if z: zList.append(z) if len(zList) > 1: if not self.isClose(float(zList[-1].groups()[0]),float(zList[-2].groups()[0])): self.data.zMoves.append(index-1) else: self.data.zMoves.append(index) except: self.data.message_queue.put("Message: Cannot reopen gcode file. It may have been moved or deleted. To locate it or open a different file use Actions > Open G-code") self.data.gcodeFile = "" def centerCanvas(self, *args): ''' Return the canvas to the center of the screen. ''' mat = Matrix().translate(Window.width/2, Window.height/2, 0) self.scatterInstance.transform = mat anchor = (0,0) scale = self.data.config.get('Ground Control Settings', 'viewScale') mat = Matrix().scale(dp(scale), dp(scale), 1) self.scatterInstance.apply_transform(mat, anchor) def on_touch_up(self, touch, *args): if touch.is_mouse_scrolling: self.zoomCanvas(touch) return super(GcodeCanvas, self).on_touch_up(touch, *args) def zoomCanvas(self, touch): if touch.is_mouse_scrolling: scaleFactor = .1 if touch.button == 'scrollup': mat = Matrix().scale(1-scaleFactor, 1-scaleFactor, 1) self.scatterInstance.apply_transform(mat, anchor = touch.pos) elif touch.button == 'scrolldown': mat = Matrix().scale(1+scaleFactor, 1+scaleFactor, 1) self.scatterInstance.apply_transform(mat, anchor = touch.pos) def drawWorkspace(self, *args): self.scatterObject.canvas.remove_group('workspace') with self.scatterObject.canvas: Color(.47, .47, .47) #create the bounding box height = float(self.data.config.get('Maslow Settings', 'bedHeight')) width = float(self.data.config.get('Maslow Settings', 'bedWidth')) Line(points = ( -width/2 , -height/2 , width/2 , -height/2), group='workspace') Line(points = ( -width/2 , height/2 , width/2 , height/2), group='workspace') Line(points = ( -width/2 , -height/2 , -width/2 , height/2), group='workspace') Line(points = ( width/2 , -height/2 , width/2 , height/2), group='workspace') #create the axis lines Line(points = (-width/2,0,width/2,0), dash_offset = 5, group='workspace') Line(points = (0, -height/2,0,height/2), dash_offset = 5, group='workspace') texture = self.data.backgroundTexture if texture is not None: Rectangle(texture=texture, pos=(-width/2, -height/2), size=(width, height), tex_coords=self.data.backgroundManualReg) def drawLine(self,gCodeLine,command): ''' drawLine draws a line using the previous command as the start point and the xy coordinates from the current command as the end point. The line is styled based on the command to allow visually differentiating between normal and rapid moves. If the z-axis depth is changed a circle is placed at the location of the depth change to alert the user. ''' try: xTarget = self.xPosition yTarget = self.yPosition zTarget = self.zPosition x = re.search("X(?=.)(([ ]*)?[+-]?([0-9]*)(\.([0-9]+))?)", gCodeLine) if x: xTarget = float(x.groups()[0])*self.canvasScaleFactor if self.absoluteFlag == 1: xTarget = self.xPosition + xTarget y = re.search("Y(?=.)(([ ]*)?[+-]?([0-9]*)(\.([0-9]+))?)", gCodeLine) if y: yTarget = float(y.groups()[0])*self.canvasScaleFactor if self.absoluteFlag == 1: yTarget = self.yPosition + yTarget z = re.search("Z(?=.)(([ ]*)?[+-]?([0-9]*)(\.([0-9]+))?)", gCodeLine) if z: zTarget = float(z.groups()[0])*self.canvasScaleFactor #Draw lines for G1 and G0 with self.scatterObject.canvas: Color(self.data.drawingColor[0], self.data.drawingColor[1], self.data.drawingColor[2]) if command == 'G00': #draw a dashed line Line(points = (self.xPosition , self.yPosition , xTarget, yTarget), width = 1, group = 'gcode', dash_length = 4, dash_offset = 2) #start a new straight line from the end of the dashed line self.line = Line(points = (), width = 1, group = 'gcode') self.addPoint(xTarget , yTarget) else: self.addPoint(xTarget , yTarget) #If the zposition has changed, add indicators tol = 0.05 #Acceptable error in mm if abs(zTarget - self.zPosition) >= tol: with self.scatterObject.canvas: if zTarget - self.zPosition > 0: Color(0, 1, 0) radius = 1 else: Color(1, 0, 0) radius = 2 Line(circle=(self.xPosition , self.yPosition, radius), group = 'gcode') Color(self.data.drawingColor[0], self.data.drawingColor[1], self.data.drawingColor[2]) self.xPosition = xTarget self.yPosition = yTarget self.zPosition = zTarget except: print "Unable to draw line on screen: " + gCodeLine def drawArc(self,gCodeLine,command): ''' drawArc draws an arc using the previous command as the start point, the xy coordinates from the current command as the end point, and the ij coordinates from the current command as the circle center. Clockwise or counter-clockwise travel is based on the command. ''' try: xTarget = self.xPosition yTarget = self.yPosition iTarget = 0 jTarget = 0 x = re.search("X(?=.)(([ ]*)?[+-]?([0-9]*)(\.([0-9]+))?)", gCodeLine) if x: xTarget = float(x.groups()[0])*self.canvasScaleFactor y = re.search("Y(?=.)(([ ]*)?[+-]?([0-9]*)(\.([0-9]+))?)", gCodeLine) if y: yTarget = float(y.groups()[0])*self.canvasScaleFactor i = re.search("I(?=.)(([ ]*)?[+-]?([0-9]*)(\.([0-9]+))?)", gCodeLine) if i: iTarget = float(i.groups()[0])*self.canvasScaleFactor j = re.search("J(?=.)(([ ]*)?[+-]?([0-9]*)(\.([0-9]+))?)", gCodeLine) if j: jTarget = float(j.groups()[0])*self.canvasScaleFactor radius = math.sqrt(iTarget**2 + jTarget**2) centerX = self.xPosition + iTarget centerY = self.yPosition + jTarget angle1 = math.atan2(self.yPosition - centerY, self.xPosition - centerX) angle2 = math.atan2(yTarget - centerY, xTarget - centerX) #atan2 returns results from -pi to +pi and we want results from 0 - 2pi if angle1 < 0: angle1 = angle1 + 2*math.pi if angle2 < 0: angle2 = angle2 + 2*math.pi #take into account command G1 or G2 if int(command[1:]) == 2: if angle1 < angle2: angle1 = angle1 + 2*math.pi direction = -1 else: if angle2 < angle1: angle2 = angle2 + 2*math.pi direction = 1 arcLen = abs(angle1 - angle2) if abs(angle1 - angle2) == 0: arcLen = 6.28313530718 i = 0 while abs(i) < arcLen: xPosOnLine = centerX + radius*math.cos(angle1 + i) yPosOnLine = centerY + radius*math.sin(angle1 + i) self.addPoint(xPosOnLine , yPosOnLine) i = i+.1*direction #this is going to need to be a direction self.addPoint(xTarget , yTarget) self.xPosition = xTarget self.yPosition = yTarget except: print "Unable to draw arc on screen: " + gCodeLine def clearGcode(self): ''' clearGcode deletes the lines and arcs corresponding to gcode commands from the canvas. ''' self.scatterObject.canvas.clear()#remove_group('gcode') self.drawWorkspace() def moveToPos(self, xPosition, yPosition, *args): ''' Move the machine to a point selected on the screen ''' if self.data.units == 'MM': scaleFactor = 1 else: scaleFactor = 25.4 xTarget = '%.3f'%(xPosition/scaleFactor) yTarget = '%.3f'%(yPosition/scaleFactor) commandString = 'G0 X' + str(xTarget) + ' Y' + str(yTarget) + " " self.data.gcode_queue.put(commandString) def createMark(self, xPosition, yPosition, *args): ''' Create a mark at a point selected on the screen ''' if self.data.units == 'MM': scaleFactor = 1 else: scaleFactor = 25.4 xTarget = xPosition/scaleFactor yTarget = yPosition/scaleFactor marker = PositionIndicator() marker.setPos(xTarget, yTarget, self.data.units) marker.color = (0,1,0) self.scatterInstance.add_widget(marker) def setHome(self, xPosition, yPosition, *args): ''' Set the home position to the location set in the long press menu ''' if self.data.units == 'MM': scaleFactor = 1 else: scaleFactor = 25.4 xPosition = xPosition/scaleFactor yPosition = yPosition/scaleFactor self.data.gcodeShift = [xPosition,yPosition] self.data.config.set('Advanced Settings', 'homeX', str(xPosition)) self.data.config.set('Advanced Settings', 'homeY', str(yPosition)) def doNothing(self, *args): ''' A placeholder function which does nothing ''' pass def moveLine(self, gCodeLine): originalLine = gCodeLine try: gCodeLine = gCodeLine.upper() + " " x = re.search("X(?=.)(([ ]*)?[+-]?([0-9]*)(\.([0-9]+))?)", gCodeLine) if x: # xTarget = '%f' % (float(x.groups()[0]) + self.data.gcodeShift[0]) # not used any more... eNtnX = re.sub('\-?\d\.|\d*e-','',str(abs(float(x.groups()[0]) + self.data.gcodeShift[0]))) # strip off everything but the decimal part or e-notation exponent e = re.search(".*e-", str(abs(float(x.groups()[0]) + self.data.gcodeShift[0]))) if e: fmtX = "%0%.%sf" % eNtnX # if e-notation, use the exponent from the e notation else: fmtX = "%0%.%sf" % len(eNtnX) # use the number of digits after the decimal place gCodeLine = gCodeLine[0:x.start()+1] + (fmtX % (float(x.groups()[0]) + self.data.gcodeShift[0])) + gCodeLine[x.end():] y = re.search("Y(?=.)(([ ]*)?[+-]?([0-9]*)(\.([0-9]+))?)", gCodeLine) if y: # yTarget = '%f' % (float(y.groups()[0]) + self.data.gcodeShift[1]) # not used any more... eNtnY = re.sub('\-?\d\.|\d*e-','',str(abs(float(y.groups()[0]) + self.data.gcodeShift[1]))) e = re.search(".*e-", str(abs(float(y.groups()[0]) + self.data.gcodeShift[1]))) if e: fmtY = "%0%.%sf" % eNtnY else: fmtY = "%0%.%sf" % len(eNtnY) gCodeLine = gCodeLine[0:y.start()+1] + (fmtY % (float(y.groups()[0]) + self.data.gcodeShift[1])) + gCodeLine[y.end():] return gCodeLine except ValueError: print "line could not be moved:" print originalLine return originalLine def loadNextLine(self): ''' Load the next line of gcode ''' try: self.data.gcode[self.lineNumber] = self.moveLine(self.data.gcode[self.lineNumber]) #move the line if the gcode has been moved fullString = self.data.gcode[self.lineNumber] self.lineNumber = self.lineNumber + 1 except: return #we have reached the end of the file #if the line contains multiple gcode commands split them and execute them individually listOfLines = fullString.split('G') if len(listOfLines) > 1: #if the line contains at least one 'G' for line in listOfLines: if len(line) > 0: #If the line is not blank self.updateOneLine('G' + line) #Draw it else: self.updateOneLine(fullString) def updateOneLine(self, fullString): ''' Draw the next line on the gcode canvas ''' validPrefixList = ['G00','G0 ','G1 ','G01','G2 ','G02','G3 ','G03', 'G17'] fullString = fullString + " " #ensures that there is a space at the end of the line #find 'G' anywhere in string gString = fullString[fullString.find('G'):fullString.find('G') + 3] if gString in validPrefixList: self.prependString = gString if fullString.find('G') == -1: #this adds the gcode operator if it is omitted by the program fullString = self.prependString + ' ' + fullString gString = self.prependString if gString == 'G00' or gString == 'G0 ': self.drawLine(fullString, 'G00') if gString == 'G01' or gString == 'G1 ': self.drawLine(fullString, 'G01') if gString == 'G02' or gString == 'G2 ': self.drawArc(fullString, 'G02') if gString == 'G03' or gString == 'G3 ': self.drawArc(fullString, 'G03') if gString == 'G17': #Take no action, XY coordinate plane is the default pass if gString == 'G18': print "G18 not supported" if gString == 'G20': self.canvasScaleFactor = self.INCHES self.data.units = "INCHES" if gString == 'G21': self.canvasScaleFactor = self.MILLIMETERS self.data.units = "MM" if gString == 'G90': self.absoluteFlag = 0 if gString == 'G91': self.absoluteFlag = 1 def callBackMechanism(self, callback) : ''' Call the loadNextLine function periodically in a non-blocking way to update the gcode. ''' with self.scatterObject.canvas: self.line = Line(points = (), width = 1, group = 'gcode') #Draw numberOfTimesToCall lines on the canvas numberOfTimesToCall = 500 for _ in range(numberOfTimesToCall): self.loadNextLine() #Repeat until end of file if self.lineNumber < min(len(self.data.gcode),self.maxNumberOfLinesToRead): Clock.schedule_once(self.callBackMechanism) def updateGcode(self, *args): ''' updateGcode parses the gcode commands and calls the appropriate drawing function for the specified command. ''' #reset variables self.data.backgroundRedraw = False self.xPosition = self.data.gcodeShift[0]*self.canvasScaleFactor self.yPosition = self.data.gcodeShift[1]*self.canvasScaleFactor self.zPosition = 0 self.prependString = "G00 " self.lineNumber = 0 self.clearGcode() #Check to see if file is too large to load if len(self.data.gcode) > self.maxNumberOfLinesToRead: errorText = "The current file contains " + str(len(self.data.gcode)) + " lines of gcode.\nrendering all " + str(len(self.data.gcode)) + " lines simultaneously may crash the\n program, only the first " + self.maxNumberOfLinesToRead + "lines are shown here.\nThe complete program will cut if you choose to do so unless the home position is moved from (0,0)." print errorText self.data.message_queue.put("Message: " + errorText) self.callBackMechanism(self.updateGcode)