from kivy.uix.scatter import Scatter from kivy.app import App from kivy.lang import Builder from kivy.uix.screenmanager import ScreenManager, Screen from kivy.logger import Logger, LOG_LEVELS from kivy.graphics import Color, Line, Scale, Translate, PopMatrix, PushMatrix, Rectangle from kivy.graphics import InstructionGroup from kivy.properties import NumericProperty, BooleanProperty, ListProperty from kivy.graphics.transformation import Matrix from kivy.core.window import Window from kivy.uix.boxlayout import BoxLayout from kivy.uix.image import Image from kivy.clock import Clock, mainthread from kivy.core.text import Label as CoreLabel from message_box import MessageBox import logging import sys import re import math import time import threading import traceback Builder.load_string(''' <GcodeViewerScreen>: on_enter: self.loading() on_leave: self.clear() BoxLayout: orientation: 'vertical' Label: text: "{} size: {}x{}".format(app.gcode_file, root.bounds[0], root.bounds[1]) size_hint_y: None height: self.texture_size[1] BoxLayout: canvas.before: Color: rgb: 0.5, 0.5, 0.5, 0.5 Rectangle: size: self.size id: view_window pos_hint: {'top': 1} Scatter: id: surface on_transform_with_touch: root.moved(*args) do_collide_after_children: True do_rotation: False canvas.before: ScissorPush: # without this we can see the scatter underneath the buttons in the rest of the window x: self.parent.pos[0] y: self.parent.pos[1] width: self.parent.width height: self.parent.height Color: rgb: (1, 1, 1, 1) if root.valid else (0,0,0,1) Rectangle: size: self.size canvas.after: ScissorPop: BoxLayout: orientation: 'horizontal' size_hint_y: None height: 40 Label: canvas.before: Color: rgb: 0, 1, 0, 1 Rectangle: size: self.size id: z_value text: 'Z{}'.format(round(root.current_z, 1)) size_hint_x: None width: self.texture_size[0] Button: text: 'First Layer' disabled: root.twod_mode on_press: root.loading(1) Button: text: 'Prev Layer' disabled: root.twod_mode on_press: root.prev_layer() Button: text: 'Next Layer' disabled: root.twod_mode on_press: root.next_layer() Spinner: text_autoupdate: True values: ('3D', '2D', 'Laser') if not app.is_cnc else ('2D', 'Laser', '3D') on_text: root.set_type(self.text) ToggleButton: id: select_mode_but text: 'Select' on_press: root.select(self.state == 'down') Button: text: 'Set WPOS' disabled: not root.select_mode on_press: root.set_wcs() Button: text: 'Move to' disabled: not root.select_mode on_press: root.move_gantry() Button: text: 'Run' disabled: not app.is_connected on_press: root.print() Button: text: 'Back' on_press: root.manager.current = 'main' ''') XY = 0 XZ = 1 CNC_accuracy = 0.001 class GcodeViewerScreen(Screen): current_z = NumericProperty(0) select_mode = BooleanProperty(False) twod_mode = BooleanProperty(False) laser_mode = BooleanProperty(False) valid = BooleanProperty(False) bounds = ListProperty([0, 0]) def __init__(self, comms=None, **kwargs): super(GcodeViewerScreen, self).__init__(**kwargs) self.app = App.get_running_app() self.last_file_pos = None self.canv = InstructionGroup() self.bind(pos=self._redraw, size=self._redraw) self.last_target_layer = 0 self.tx = 0 self.ty = 0 self.scale = 1.0 self.comms = comms self.twod_mode = self.app.is_cnc self.rval = 0.0 def loading(self, ll=1): self.valid = False self.li = Image(source='img/image-loading.gif') self.add_widget(self.li) self.ids.surface.canvas.remove(self.canv) threading.Thread(target=self._load_file, args=(ll,)).start() @mainthread def _loaded(self): Logger.debug("GcodeViewerScreen: in _loaded. ok: {}".format(self._loaded_ok)) self.remove_widget(self.li) self.li = None self.ids.surface.canvas.add(self.canv) self.valid = self._loaded_ok if self._loaded_ok: # not sure why we need to do this self.ids.surface.top = Window.height if self.app.is_connected: self.app.bind(wpos=self.update_tool) def _load_file(self, ll): self._loaded_ok = False try: self.parse_gcode_file(self.app.gcode_file, ll, True) except Exception: print(traceback.format_exc()) mb = MessageBox(text='File not found: {}'.format(self.app.gcode_file)) mb.open() self._loaded() def _redraw(self, instance, value): self.ids.surface.canvas.remove(self.canv) self.ids.surface.canvas.add(self.canv) def clear(self): self.app.unbind(wpos=self.update_tool) if self.li: self.remove_widget(self.li) self.li = None if self.select_mode: self.stop_cursor(0, 0) self.select_mode = False self.ids.select_mode_but.state = 'normal' self.valid = False self.is_visible = False self.canv.clear() self.ids.surface.canvas.remove(self.canv) self.last_target_layer = 0 # reset scale and translation m = Matrix() m.identity() self.ids.surface.transform = m # not sure why we need to do this self.ids.surface.top = Window.height def next_layer(self): self.loading(self.last_target_layer + 1) def prev_layer(self): n = 1 if self.last_target_layer <= 1 else self.last_target_layer - 1 self.loading(n) def print(self): self.app.main_window._start_print() # ---------------------------------------------------------------------- # Return center x,y,z,r for arc motions 2,3 and set self.rval # Cribbed from bCNC # ---------------------------------------------------------------------- def motionCenter(self, gcode, plane, xyz_cur, xyz_val, ival, jval, kval=0.0): if self.rval > 0.0: if plane == XY: x = xyz_cur[0] y = xyz_cur[1] xv = xyz_val[0] yv = xyz_val[1] elif plane == XZ: x = xyz_cur[0] y = xyz_cur[2] xv = xyz_val[0] yv = xyz_val[2] else: x = xyz_cur[1] y = xyz_cur[2] xv = xyz_val[1] yv = xyz_val[2] ABx = xv - x ABy = yv - y Cx = 0.5 * (x + xv) Cy = 0.5 * (y + yv) AB = math.sqrt(ABx**2 + ABy**2) try: OC = math.sqrt(self.rval**2 - AB**2 / 4.0) except Exception: OC = 0.0 if gcode == 2: OC = -OC # CW if AB != 0.0: return Cx - OC * ABy / AB, Cy + OC * ABx / AB else: # Error!!! return x, y else: # Center xc = xyz_cur[0] + ival yc = xyz_cur[1] + jval zc = xyz_cur[2] + kval self.rval = math.sqrt(ival**2 + jval**2 + kval**2) if plane == XY: return xc, yc elif plane == XZ: return xc, zc else: return yc, zc extract_gcode = re.compile(r"(G|X|Y|Z|I|J|K|E|S)(-?\d*\.?\d*\.?)") def parse_gcode_file(self, fn, target_layer=0, one_layer=False): # open file parse gcode and draw Logger.debug("GcodeViewerScreen: parsing file {}". format(fn)) lastpos = [self.app.wpos[0], self.app.wpos[1], -1] # XYZ, set to initial tool position lastz = None lastdeltaz = None laste = 0 lasts = 1 layer = -1 last_gcode = -1 points = [] max_x = float('nan') max_y = float('nan') min_x = float('nan') min_y = float('nan') has_e = False plane = XY rel_move = False self.is_visible = True if self.laser_mode: self.twod_mode = True # laser mode implies 2D mode self.last_target_layer = target_layer # reset scale and translation m = Matrix() m.identity() self.ids.surface.transform = m # remove all instructions from canvas self.canv.clear() self.canv.add(PushMatrix()) modal_g = 0 cnt = 0 found_layer = False x = lastpos[0] y = lastpos[1] z = lastpos[2] with open(fn) as f: # if self.last_file_pos: # # jump to last read position # f.seek(self.last_file_pos) # self.last_file_pos= None # print('Jumped to Saved position: {}'.format(self.last_file_pos)) for ln in f: cnt += 1 ln = ln.strip() if not ln: continue if ln.startswith(';'): continue if ln.startswith('('): continue p = ln.find(';') if p >= 0: ln = ln[:p] matches = self.extract_gcode.findall(ln) # this handles multiple G codes on one line gcodes = [] d = {} for m in matches: #print(m) if m[0] == 'G' and 'G' in d: # we have another G code on the same line gcodes.append(d) d = {} d[m[0]] = float(m[1]) gcodes.append(d) for d in gcodes: if not d: continue Logger.debug("GcodeViewerScreen: d={}".format(d)) # handle modal commands if 'G' not in d and ('X' in d or 'Y' in d or 'Z' in d or 'S' in d): d['G'] = modal_g gcode = int(d['G']) # G92 E0 resets E if 'G' in d and gcode == 92 and 'E' in d: laste = float(d['E']) has_e = True if 'G' in d and (gcode == 91 or gcode == 90): rel_move = gcode == 91 # only deal with G0/1/2/3 if gcode > 3: continue modal_g = gcode # see if it is 3d printing (ie has an E axis on a G1) if not has_e and ('E' in d and 'G' in d and gcode == 1): has_e = True if rel_move: x += 0 if 'X' not in d else float(d['X']) y += 0 if 'Y' not in d else float(d['Y']) z += 0 if 'Z' not in d else float(d['Z']) else: x = lastpos[0] if 'X' not in d else float(d['X']) y = lastpos[1] if 'Y' not in d else float(d['Y']) z = lastpos[2] if 'Z' not in d else float(d['Z']) i = 0.0 if 'I' not in d else float(d['I']) j = 0.0 if 'J' not in d else float(d['J']) self.rval = 0.0 if 'R' not in d else float(d['R']) e = laste if 'E' not in d else float(d['E']) s = lasts if 'S' not in d else float(d['S']) if not self.twod_mode : # handle layers (when Z changes) if z == -1: # no z seen yet layer = -1 continue if lastz is None: # first layer lastz = z layer = 1 if z != lastz: # count layers layer += 1 lastz = z # wait until we get to the requested layer if layer != target_layer: lastpos[2] = z continue if layer > target_layer and one_layer: # FIXME for some reason this does not work, -- not counting layers #self.last_file_pos= f.tell() #print('Saved position: {}'.format(self.last_file_pos)) break self.current_z = z found_layer = True Logger.debug("GcodeViewerScreen: x= {}, y= {}, z= {}, s= {}".format(x, y, z, s)) # find bounding box if math.isnan(min_x) or x < min_x: min_x = x if math.isnan(min_y) or y < min_y: min_y = y if math.isnan(max_x) or x > max_x: max_x = x if math.isnan(max_y) or y > max_y: max_y = y # accumulating vertices is more efficient but we need to flush them at some point # Here we flush them if we encounter a new G code like G3 following G1 if last_gcode != gcode: # flush vertices if points: self.canv.add(Color(0, 0, 0)) self.canv.add(Line(points=points, width=1, cap='none', joint='none')) points = [] last_gcode = gcode # in slicer generated files there is no G0 so we need a way to know when to draw, so if there is an E then draw else don't if gcode == 0: #print("move to: {}, {}, {}".format(x, y, z)) # draw moves in dashed red self.canv.add(Color(1, 0, 0)) self.canv.add(Line(points=[lastpos[0], lastpos[1], x, y], width=1, dash_offset=1, cap='none', joint='none')) elif gcode == 1: if ('X' in d or 'Y' in d): if self.laser_mode and s <= 0.01: # do not draw non cutting lines if points: # draw accumulated points upto this point self.canv.add(Color(0, 0, 0)) self.canv.add(Line(points=points, width=1, cap='none', joint='none')) points = [] # for 3d printers (has_e) only draw if there is an E elif not has_e or 'E' in d: # if a CNC gcode file or there is an E in the G1 (3d printing) #print("draw to: {}, {}, {}".format(x, y, z)) # collect points but don't draw them yet if len(points) < 2: points.append(lastpos[0]) points.append(lastpos[1]) points.append(x) points.append(y) else: # a G1 with no E, treat as G0 and draw moves in red #print("move to: {}, {}, {}".format(x, y, z)) if points: # draw accumulated points upto this point self.canv.add(Color(0, 0, 0)) self.canv.add(Line(points=points, width=1, cap='none', joint='none')) points = [] # now draw the move in red self.canv.add(Color(1, 0, 0)) self.canv.add(Line(points=[lastpos[0], lastpos[1], x, y], width=1, cap='none', joint='none')) else: # A G1 with no X or Y, maybe E only move (retract) or Z move (layer change) if points: # draw accumulated points upto this point self.canv.add(Color(0, 0, 0)) self.canv.add(Line(points=points, width=1, cap='none', joint='none')) points = [] elif gcode in [2, 3]: # CW=2,CCW=3 circle # code cribbed from bCNC xyz = [] xyz.append((lastpos[0], lastpos[1], lastpos[2])) uc, vc = self.motionCenter(gcode, plane, lastpos, [x, y, z], i, j) if plane == XY: u0 = lastpos[0] v0 = lastpos[1] w0 = lastpos[2] u1 = x v1 = y w1 = z elif plane == XZ: u0 = lastpos[0] v0 = lastpos[2] w0 = lastpos[1] u1 = x v1 = z w1 = y gcode = 5 - gcode # flip 2-3 when XZ plane is used else: u0 = lastpos[1] v0 = lastpos[2] w0 = lastpos[0] u1 = y v1 = z w1 = x phi0 = math.atan2(v0-vc, u0-uc) phi1 = math.atan2(v1-vc, u1-uc) try: sagitta = 1.0-CNC_accuracy/self.rval except ZeroDivisionError: sagitta = 0.0 if sagitta > 0.0: df = 2.0*math.acos(sagitta) df = min(df, math.pi/4.0) else: df = math.pi/4.0 if gcode == 2: if phi1 >= phi0-1e-10: phi1 -= 2.0*math.pi ws = (w1-w0)/(phi1-phi0) phi = phi0 - df while phi > phi1: u = uc + self.rval*math.cos(phi) v = vc + self.rval*math.sin(phi) w = w0 + (phi-phi0)*ws phi -= df if plane == XY: xyz.append((u,v,w)) elif plane == XZ: xyz.append((u,w,v)) else: xyz.append((w,u,v)) else: if phi1 <= phi0+1e-10: phi1 += 2.0*math.pi ws = (w1-w0)/(phi1-phi0) phi = phi0 + df while phi < phi1: u = uc + self.rval*math.cos(phi) v = vc + self.rval*math.sin(phi) w = w0 + (phi-phi0)*ws phi += df if plane == XY: xyz.append((u,v,w)) elif plane == XZ: xyz.append((u,w,v)) else: xyz.append((w,u,v)) xyz.append((x, y, z)) # plot the points points = [] for t in xyz: x1, y1, z1 = t points.append(x1) points.append(y1) max_x = max(x1, max_x) min_x = min(x1, min_x) max_y = max(y1, max_y) min_y = min(y1, min_y) self.canv.add(Color(0, 0, 0)) self.canv.add(Line(points=points, width=1, cap='none', joint='none')) points = [] # always remember last position lastpos = [x, y, z] laste = e lasts = s if not found_layer: # we hit the end of file before finding the layer we want Logger.info("GcodeViewerScreen: last layer was at {}".format(lastz)) self.last_target_layer -= 1 return # flush any points not yet drawn if points: # draw accumulated points upto this point self.canv.add(Color(0, 0, 0)) self.canv.add(Line(points=points, width=1, cap='none', joint='none')) points = [] # center the drawing and scale it dx = max_x - min_x dy = max_y - min_y if dx == 0 or dy == 0: Logger.warning("GcodeViewerScreen: size is bad, maybe need 2D mode") return self.bounds = [dx, dy] dx += 4 dy += 4 Logger.debug("GcodeViewerScreen: dx= {}, dy= {}".format(dx, dy)) # add in the translation to center object self.tx = -min_x - dx / 2 self.ty = -min_y - dy / 2 self.canv.insert(1, Translate(self.tx, self.ty)) Logger.debug("GcodeViewerScreen: tx= {}, ty= {}".format(self.tx, self.ty)) # scale the drawing to fit the screen if abs(dx) > abs(dy): scale = self.ids.surface.width / abs(dx) if abs(dy) * scale > self.ids.surface.height: scale *= self.ids.surface.height / (abs(dy) * scale) else: scale = self.ids.surface.height / abs(dy) if abs(dx) * scale > self.ids.surface.width: scale *= self.ids.surface.width / (abs(dx) * scale) Logger.debug("GcodeViewerScreen: scale= {}".format(scale)) self.scale = scale self.canv.insert(1, Scale(scale)) # translate to center of canvas self.offs = self.ids.surface.center self.canv.insert(1, Translate(self.ids.surface.center[0], self.ids.surface.center[1])) Logger.debug("GcodeViewerScreen: cx= {}, cy= {}".format(self.ids.surface.center[0], self.ids.surface.center[1])) Logger.debug("GcodeViewerScreen: sx= {}, sy= {}".format(self.ids.surface.size[0], self.ids.surface.size[1])) # axis Markers self.canv.add(Color(0, 1, 0, mode='rgb')) self.canv.add(Line(points=[0, -10, 0, self.ids.surface.height / scale], width=1, cap='none', joint='none')) self.canv.add(Line(points=[-10, 0, self.ids.surface.width / scale, 0], width=1, cap='none', joint='none')) # tool position marker if self.app.is_connected: x = self.app.wpos[0] y = self.app.wpos[1] r = (10.0 / self.ids.surface.scale) / scale self.canv.add(Color(1, 0, 0, mode='rgb', group="tool")) self.canv.add(Line(circle=(x, y, r), group="tool")) # self.canv.add(Rectangle(pos=(x, y-r/2), size=(1/scale, r), group="tool")) # self.canv.add(Rectangle(pos=(x-r/2, y), size=(r, 1/scale), group="tool")) self.canv.add(PopMatrix()) self._loaded_ok = True Logger.debug("GcodeViewerScreen: done loading") def update_tool(self, i, v): if not self.is_visible or not self.app.is_connected: return # follow the tool path #self.canv.remove_group("tool") x = v[0] y = v[1] r = (10.0/self.ids.surface.scale)/self.scale g = self.canv.get_group("tool") if g: g[2].circle = (x, y, r) # g[4].pos= x, y-r/2 # g[6].pos= x-r/2, y def transform_to_wpos(self, posx, posy): ''' convert touch coords to local scatter widget coords, relative to lower bottom corner ''' pos = self.ids.surface.to_widget(posx, posy) # convert to original model coordinates (mm), need to take into account scale and translate wpos = ((pos[0] - self.offs[0]) / self.scale - self.tx, (pos[1] - self.offs[1]) / self.scale - self.ty) return wpos def transform_to_spos(self, posx, posy): ''' inverse transform of model coordinates to scatter coordinates ''' pos = ((((posx + self.tx) * self.scale) + self.offs[0]), (((posy + self.ty) * self.scale) + self.offs[1])) spos = self.ids.surface.to_window(*pos) #print("pos= {}, spos= {}".format(pos, spos)) return spos def moved(self, w, touch): # we scaled or moved the scatter so need to reposition cursor # TODO it would be nice if the cursor stayed where it was relative to the model during a move or scale # NOTE right now we can't move or scale while cursor is on # if self.select_mode: # x, y= (self.crossx[0].pos[0], self.crossx[1].pos[1]) # self.stop_cursor(x, y) # self.start_cursor(x, y) # hide tool marker self.canv.remove_group('tool') def start_cursor(self, x, y): tx, ty = self.transform_to_wpos(x, y) label = CoreLabel(text="{:1.2f},{:1.2f}".format(tx, ty)) label.refresh() texture = label.texture px, py = (x, y) with self.ids.surface.canvas.after: Color(0, 0, 1, mode='rgb', group='cursor_group') self.crossx = [ Rectangle(pos=(px, 0), size=(1, self.height), group='cursor_group'), Rectangle(pos=(0, py), size=(self.width, 1), group='cursor_group'), Line(circle=(px, py, 20), group='cursor_group'), Rectangle(texture=texture, pos=(px - texture.size[0] / 2, py - 40), size=texture.size, group='cursor_group') ] def move_cursor_by(self, dx, dy): x, y = (self.crossx[0].pos[0] + dx, self.crossx[1].pos[1] + dy) self.crossx[0].pos = x, 0 self.crossx[1].pos = 0, y self.crossx[2].circle = (x, y, 20) tx, ty = self.transform_to_wpos(x, y) label = CoreLabel(text="{:1.2f},{:1.2f}".format(tx, ty)) label.refresh() texture = label.texture self.crossx[3].texture = texture self.crossx[3].pos = x - texture.size[0] / 2, y - 40 def stop_cursor(self, x=0, y=0): self.ids.surface.canvas.after.remove_group('cursor_group') self.crossx = None def on_touch_down(self, touch): #print(self.ids.surface.bbox) if self.ids.view_window.collide_point(touch.x, touch.y): # if within the scatter window if self.select_mode: touch.grab(self) return True elif touch.is_mouse_scrolling: # Allow mouse scroll wheel to zoom in/out if touch.button == 'scrolldown': # zoom in if self.ids.surface.scale < 100: rescale = 1.1 self.ids.surface.apply_transform(Matrix().scale(rescale, rescale, rescale), post_multiply=True, anchor=self.ids.surface.to_widget(*touch.pos)) elif touch.button == 'scrollup': # zoom out if self.ids.surface.scale > 0.01: rescale = 0.8 self.ids.surface.apply_transform(Matrix().scale(rescale, rescale, rescale), post_multiply=True, anchor=self.ids.surface.to_widget(*touch.pos)) self.moved(None, touch) return True return super(GcodeViewerScreen, self).on_touch_down(touch) def on_touch_move(self, touch): if self.select_mode: if touch.grab_current is not self: return False dx = touch.dpos[0] dy = touch.dpos[1] self.move_cursor_by(dx, dy) return True else: return super(GcodeViewerScreen, self).on_touch_move(touch) def on_touch_up(self, touch): if touch.grab_current is self: touch.ungrab(self) return True return super(GcodeViewerScreen, self).on_touch_up(touch) def select(self, on): if not on and self.select_mode: self.stop_cursor() self.select_mode = False elif on and not self.select_mode: x, y = self.center self.start_cursor(x, y) self.select_mode = True def move_gantry(self): if not self.select_mode: return self.select_mode = False self.ids.select_mode_but.state = 'normal' # convert to original model coordinates (mm), need to take into account scale and translate x, y = (self.crossx[0].pos[0], self.crossx[1].pos[1]) self.stop_cursor(x, y) wpos = self.transform_to_wpos(x, y) if self.comms: self.comms.write('G0 X{:1.2f} Y{:1.2f}\n'.format(wpos[0], wpos[1])) else: print('Move Gantry to: {:1.2f}, {:1.2f}'.format(wpos[0], wpos[1])) print('G0 X{:1.2f} Y{:1.2f}'.format(wpos[0], wpos[1])) def set_wcs(self): if not self.select_mode: return self.select_mode = False self.ids.select_mode_but.state = 'normal' # convert to original model coordinates (mm), need to take into account scale and translate x, y = (self.crossx[0].pos[0], self.crossx[1].pos[1]) self.stop_cursor(x, y) wpos = self.transform_to_wpos(x, y) if self.comms: self.comms.write('G10 L20 P0 X{:1.2f} Y{:1.2f}\n'.format(wpos[0], wpos[1])) else: print('Set WCS to: {:1.2f}, {:1.2f}'.format(wpos[0], wpos[1])) print('G10 L20 P0 X{:1.2f} Y{:1.2f}'.format(wpos[0], wpos[1])) def set_type(self, t): if t == '3D': self.twod_mode = False self.laser_mode = False elif t == '2D': self.twod_mode = True self.laser_mode = False elif t == 'Laser': self.twod_mode = True self.laser_mode = True self.loading(0 if self.twod_mode else 1) if __name__ == '__main__': Builder.load_string(''' <StartScreen>: Button: text: 'Quit' on_press: app.stop() <ExitScreen>: on_enter: app.stop() ''') class StartScreen(Screen): pass class ExitScreen(Screen): pass class GcodeViewerApp(App): is_cnc = BooleanProperty(False) is_connected = BooleanProperty(False) is_desktop = NumericProperty(2) wpos = ListProperty([0, 0, 0]) def __init__(self, **kwargs): super(GcodeViewerApp, self).__init__(**kwargs) if len(sys.argv) > 1: self.gcode_file = sys.argv[1] if not self.gcode_file.endswith('.gcode'): self.is_cnc = True else: self.gcode_file = 'test.gcode' # 'circle-test.g' def build(self): Window.size = (1024, 768) self.sm = ScreenManager() self.sm.add_widget(StartScreen(name='start')) self.sm.add_widget(GcodeViewerScreen(name='gcode')) self.sm.add_widget(ExitScreen(name='main')) self.sm.current = 'gcode' level = LOG_LEVELS.get('debug') if len(sys.argv) > 2 else LOG_LEVELS.get('info') Logger.setLevel(level=level) # logging.getLogger().setLevel(logging.DEBUG) return self.sm GcodeViewerApp().run()