updated and added new files for printrun

Wed, 20 Jan 2021 10:15:13 +0100

author
mdd
date
Wed, 20 Jan 2021 10:15:13 +0100
changeset 46
cce0af6351f0
parent 45
c82943fb205f
child 47
dcc64b767b64

updated and added new files for printrun

printrun-src/printrun/eventhandler.py file | annotate | diff | comparison | revisions
printrun-src/printrun/excluder.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gcodeplater.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gcoder.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gcoder_line.pyx file | annotate | diff | comparison | revisions
printrun-src/printrun/gcview.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gl/libtatlin/actors.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gl/panel.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gl/trackball.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gui/__init__.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gui/bufferedcanvas.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gui/controls.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gui/graph.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gui/log.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gui/toolbar.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gui/viz.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gui/widgets.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gui/xybuttons.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gui/zbuttons.py file | annotate | diff | comparison | revisions
printrun-src/printrun/gviz.py file | annotate | diff | comparison | revisions
printrun-src/printrun/objectplater.py file | annotate | diff | comparison | revisions
printrun-src/printrun/packer.py file | annotate | diff | comparison | revisions
printrun-src/printrun/plugins/__init__.py file | annotate | diff | comparison | revisions
printrun-src/printrun/plugins/sample.py file | annotate | diff | comparison | revisions
printrun-src/printrun/power/__init__.py file | annotate | diff | comparison | revisions
printrun-src/printrun/power/osx.py file | annotate | diff | comparison | revisions
printrun-src/printrun/printcore.py file | annotate | diff | comparison | revisions
printrun-src/printrun/projectlayer.py file | annotate | diff | comparison | revisions
printrun-src/printrun/pronsole.py file | annotate | diff | comparison | revisions
printrun-src/printrun/rpc.py file | annotate | diff | comparison | revisions
printrun-src/printrun/settings.py file | annotate | diff | comparison | revisions
printrun-src/printrun/spoolmanager/__init__.py file | annotate | diff | comparison | revisions
printrun-src/printrun/spoolmanager/spoolmanager.py file | annotate | diff | comparison | revisions
printrun-src/printrun/spoolmanager/spoolmanager_gui.py file | annotate | diff | comparison | revisions
printrun-src/printrun/stlplater.py file | annotate | diff | comparison | revisions
printrun-src/printrun/stltool.py file | annotate | diff | comparison | revisions
printrun-src/printrun/stlview.py file | annotate | diff | comparison | revisions
printrun-src/printrun/utils.py file | annotate | diff | comparison | revisions
printrun-src/printrun/zscaper.py file | annotate | diff | comparison | revisions
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/printrun-src/printrun/eventhandler.py	Wed Jan 20 10:15:13 2021 +0100
@@ -0,0 +1,126 @@
+# This file is part of the Printrun suite.
+#
+# Printrun is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Printrun 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Printrun.  If not, see <http://www.gnu.org/licenses/>.
+
+class PrinterEventHandler:
+    '''
+    Defines a skeletton of an event-handler for printer events. It
+    allows attaching to the printcore and will be triggered for
+    different events.
+    '''
+    def __init__(self):
+        '''
+        Constructor.
+        '''
+        pass
+    
+    def on_init(self):
+        '''
+        Called whenever a new printcore is initialized.
+        '''
+        pass
+    
+    def on_send(self, command, gline):
+        '''
+        Called on every command sent to the printer.
+        
+        @param command: The command to be sent.
+        @param gline: The parsed high-level command.
+        '''
+        pass
+    
+    def on_recv(self, line):
+        '''
+        Called on every line read from the printer.
+        
+        @param line: The data has been read from printer.
+        '''
+        pass
+    
+    
+    def on_connect(self):
+        '''
+        Called whenever printcore is connected.
+        '''
+        pass
+    
+    def on_disconnect(self):
+        '''
+        Called whenever printcore is disconnected.
+        '''
+        pass
+    
+    def on_error(self, error):
+        '''
+        Called whenever an error occurs.
+        
+        @param error: The error that has been triggered.
+        '''
+        pass
+    
+    def on_online(self):
+        '''
+        Called when printer got online.
+        '''
+        pass
+    
+    def on_temp(self, line):
+        '''
+        Called for temp, status, whatever.
+        
+        @param line: Line of data.
+        '''
+        pass
+    
+    def on_start(self, resume):
+        '''
+        Called when printing is started.
+        
+        @param resume: If true, the print is resumed.
+        '''
+        pass
+    
+    def on_end(self):
+        '''
+        Called when printing ends.
+        '''
+        pass
+    
+    def on_layerchange(self, layer):
+        '''
+        Called on layer changed.
+        
+        @param layer: The new layer.
+        '''
+        pass
+    
+    def on_preprintsend(self, gline, index, mainqueue):
+        '''
+        Called pre sending printing command.
+        
+        @param gline: Line to be send.
+        @param index: Index in the mainqueue.
+        @param mainqueue: The main queue of commands.
+        '''
+        pass
+    
+    def on_printsend(self, gline):
+        '''
+        Called whenever a line is sent to the printer.
+        
+        @param gline: The line send to the printer.
+        '''
+        pass
+        
+        
\ No newline at end of file
--- a/printrun-src/printrun/excluder.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/excluder.py	Wed Jan 20 10:15:13 2021 +0100
@@ -24,10 +24,9 @@
     def __init__(self, excluder, *args, **kwargs):
         super(ExcluderWindow, self).__init__(*args, **kwargs)
         self.SetTitle(_("Part excluder: draw rectangles where print instructions should be ignored"))
-        self.toolbar.AddLabelTool(128, " " + _("Reset selection"),
-                                  wx.Image(imagefile('reset.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(),
-                                  shortHelp = _("Reset selection"),
-                                  longHelp = "")
+        self.toolbar.AddTool(128, " " + _("Reset selection"),
+                             wx.Image(imagefile('reset.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(),
+                             _("Reset selection"))
         self.Bind(wx.EVT_TOOL, self.reset_selection, id = 128)
         self.parent = excluder
         self.p.paint_overlay = self.paint_selection
@@ -46,7 +45,7 @@
            or event.ButtonUp(wx.MOUSE_BTN_RIGHT):
             self.initpos = None
         elif event.Dragging() and event.RightIsDown():
-            e = event.GetPositionTuple()
+            e = event.GetPosition()
             if not self.initpos or not hasattr(self, "basetrans"):
                 self.initpos = e
                 self.basetrans = self.p.translate
@@ -55,7 +54,7 @@
             self.p.dirty = 1
             wx.CallAfter(self.p.Refresh)
         elif event.Dragging() and event.LeftIsDown():
-            x, y = event.GetPositionTuple()
+            x, y = event.GetPosition()
             if not self.initpos:
                 self.basetrans = self.p.translate
             x = (x - self.basetrans[0]) / self.p.scale[0]
@@ -97,7 +96,7 @@
         self.parent.rectangles = []
         wx.CallAfter(self.p.Refresh)
 
-class Excluder(object):
+class Excluder:
 
     def __init__(self):
         self.rectangles = []
@@ -120,7 +119,7 @@
 
 if __name__ == '__main__':
     import sys
-    import gcoder
+    from . import gcoder
     gcode = gcoder.GCode(open(sys.argv[1]))
     app = wx.App(False)
     ex = Excluder()
--- a/printrun-src/printrun/gcodeplater.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gcodeplater.py	Wed Jan 20 10:15:13 2021 +0100
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 
 # This file is part of the Printrun suite.
 #
@@ -79,17 +79,20 @@
 
     def prepare_ui(self, filenames = [], callback = None,
                    parent = None, build_dimensions = None,
-                   circular_platform = False, antialias_samples = 0):
+                   circular_platform = False,
+                   antialias_samples = 0,
+                   grid = (1, 10)):
         super(GcodePlaterPanel, self).prepare_ui(filenames, callback, parent, build_dimensions)
         viewer = gcview.GcodeViewPanel(self, build_dimensions = self.build_dimensions,
                                        antialias_samples = antialias_samples)
         self.set_viewer(viewer)
         self.platform = actors.Platform(self.build_dimensions,
-                                        circular = circular_platform)
+                                        circular = circular_platform,
+                                        grid = grid)
         self.platform_object = gcview.GCObject(self.platform)
 
     def get_objects(self):
-        return [self.platform_object] + self.models.values()
+        return [self.platform_object] + list(self.models.values())
     objects = property(get_objects)
 
     def load_file(self, filename):
@@ -99,9 +102,9 @@
         if gcode.filament_length > 0:
             model.display_travels = False
         generator = model.load_data(gcode)
-        generator_output = generator.next()
+        generator_output = next(generator)
         while generator_output is not None:
-            generator_output = generator.next()
+            generator_output = next(generator)
         obj = gcview.GCObject(model)
         obj.offsets = [self.build_dimensions[3], self.build_dimensions[4], 0]
         obj.gcode = gcode
@@ -142,7 +145,7 @@
         return self.export_sequential(name)
 
     def export_combined(self, name):
-        models = self.models.values()
+        models = list(self.models.values())
         last_real_position = None
         # Sort models by Z max to print smaller objects first
         models.sort(key = lambda x: x.dims[-1])
@@ -151,7 +154,7 @@
             def add_offset(layer):
                 return layer.z + model.offsets[2] if layer.z is not None else layer.z
             alllayers += [(add_offset(layer), model_i, layer_i)
-                          for (layer_i, layer) in enumerate(model.gcode.all_layers) if layer]
+                          for (layer_i, layer) in enumerate(model.gcode.all_layers) if add_offset(layer) is not None]
         alllayers.sort()
         laste = [0] * len(models)
         lasttool = [0] * len(models)
@@ -196,7 +199,7 @@
         logging.info(_("Exported merged G-Codes to %s") % name)
 
     def export_sequential(self, name):
-        models = self.models.values()
+        models = list(self.models.values())
         last_real_position = None
         # Sort models by Z max to print smaller objects first
         models.sort(key = lambda x: x.dims[-1])
@@ -223,7 +226,7 @@
                         else:
                             f.write(rewrite_gline(co, l, math.cos(r), math.sin(r)) + "\n")
                 # Find the current real position
-                for i in xrange(len(model.gcode) - 1, -1, -1):
+                for i in range(len(model.gcode) - 1, -1, -1):
                     gline = model.gcode.lines[i]
                     if gline.is_move:
                         last_real_position = (- trans[0] + gline.current_x,
--- a/printrun-src/printrun/gcoder.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gcoder.py	Wed Jan 20 10:15:13 2021 +0100
@@ -1,12 +1,13 @@
-#!/usr/bin/env python
-# This file is copied from GCoder.
+#!/usr/bin/env python3
 #
-# GCoder is free software: you can redistribute it and/or modify
+# This file is part of the Printrun suite.
+#
+# Printrun is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
 # the Free Software Foundation, either version 3 of the License, or
 # (at your option) any later version.
 #
-# GCoder is distributed in the hope that it will be useful,
+# Printrun 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 General Public License for more details.
@@ -22,15 +23,15 @@
 from array import array
 
 gcode_parsed_args = ["x", "y", "e", "f", "z", "i", "j"]
-gcode_parsed_nonargs = ["g", "t", "m", "n"]
-to_parse = "".join(gcode_parsed_args + gcode_parsed_nonargs)
-gcode_exp = re.compile("\([^\(\)]*\)|;.*|[/\*].*\n|([%s])([-+]?[0-9]*\.?[0-9]*)" % to_parse)
+gcode_parsed_nonargs = 'gtmnd'
+to_parse = "".join(gcode_parsed_args) + gcode_parsed_nonargs
+gcode_exp = re.compile("\([^\(\)]*\)|;.*|[/\*].*\n|([%s])\s*([-+]?[0-9]*\.?[0-9]*)" % to_parse)
 gcode_strip_comment_exp = re.compile("\([^\(\)]*\)|;.*|[/\*].*\n")
 m114_exp = re.compile("\([^\(\)]*\)|[/\*].*\n|([XYZ]):?([-+]?[0-9]*\.?[0-9]*)")
 specific_exp = "(?:\([^\(\)]*\))|(?:;.*)|(?:[/\*].*\n)|(%s[-+]?[0-9]*\.?[0-9]*)"
 move_gcodes = ["G0", "G1", "G2", "G3"]
 
-class PyLine(object):
+class PyLine:
 
     __slots__ = ('x', 'y', 'z', 'e', 'f', 'i', 'j',
                  'raw', 'command', 'is_move',
@@ -45,7 +46,7 @@
     def __getattr__(self, name):
         return None
 
-class PyLightLine(object):
+class PyLightLine:
 
     __slots__ = ('raw', 'command')
 
@@ -56,10 +57,10 @@
         return None
 
 try:
-    import gcoder_line
+    from . import gcoder_line
     Line = gcoder_line.GLine
     LightLine = gcoder_line.GLightLine
-except Exception, e:
+except Exception as e:
     logging.warning("Memory-efficient GCoder implementation unavailable: %s" % e)
     Line = PyLine
     LightLine = PyLightLine
@@ -107,8 +108,9 @@
     def __init__(self, lines, z = None):
         super(Layer, self).__init__(lines)
         self.z = z
+        self.duration = 0
 
-class GCode(object):
+class GCode:
 
     line_class = Line
 
@@ -121,6 +123,7 @@
     append_layer_id = None
 
     imperial = False
+    cutting = False
     relative = False
     relative_e = False
     current_tool = 0
@@ -217,7 +220,9 @@
     layers_count = property(_get_layers_count)
 
     def __init__(self, data = None, home_pos = None,
-                 layer_callback = None, deferred = False):
+                 layer_callback = None, deferred = False,
+                 cutting_as_extrusion = False):
+        self.cutting_as_extrusion = cutting_as_extrusion
         if not deferred:
             self.prepare(data, home_pos, layer_callback)
 
@@ -240,6 +245,8 @@
             self.layer_idxs = array('I', [])
             self.line_idxs = array('I', [])
 
+    def has_index(self, i):
+        return i < len(self)
     def __len__(self):
         return len(self.line_idxs)
 
@@ -314,7 +321,7 @@
             self.lines.append(gline)
             self.append_layer.append(gline)
             self.layer_idxs.append(self.append_layer_id)
-            self.line_idxs.append(len(self.append_layer))
+            self.line_idxs.append(len(self.append_layer)-1)
         return gline
 
     def _preprocess(self, lines = None, build_layers = False,
@@ -338,6 +345,7 @@
         offset_e = self.offset_e
         total_e = self.total_e
         max_e = self.max_e
+        cutting = self.cutting
 
         current_e_multi = self.current_e_multi[current_tool]
         offset_e_multi = self.offset_e_multi[current_tool]
@@ -367,7 +375,8 @@
             # get device caps from firmware: max speed, acceleration/axis
             # (including extruder)
             # calculate the maximum move duration accounting for above ;)
-            lastx = lasty = lastz = laste = lastf = 0.0
+            lastx = lasty = lastz = None
+            laste = lastf = 0
             lastdx = 0
             lastdy = 0
             x = y = e = f = 0.0
@@ -383,15 +392,41 @@
             layer_idxs = self.layer_idxs = []
             line_idxs = self.line_idxs = []
 
-            layer_id = 0
-            layer_line = 0
 
             last_layer_z = None
             prev_z = None
-            prev_base_z = (None, None)
             cur_z = None
             cur_lines = []
 
+            def append_lines(lines, isEnd):
+                if not build_layers:
+                    return
+                nonlocal layerbeginduration, last_layer_z
+                if cur_layer_has_extrusion and prev_z != last_layer_z \
+                        or not all_layers or isEnd:
+                    layer = Layer([], prev_z)
+                    last_layer_z = prev_z
+                    finished_layer = len(all_layers)-1 if all_layers else None
+                    all_layers.append(layer)
+                else:
+                    layer = all_layers[-1]
+                    finished_layer = None
+                layer_id = len(all_layers)-1
+                layer_line = len(layer)
+                for i, ln in enumerate(lines):
+                    layer.append(ln)
+                    layer_idxs.append(layer_id)
+                    line_idxs.append(layer_line+i)
+                layer.duration += totalduration - layerbeginduration
+                layerbeginduration = totalduration
+                if layer_callback:
+                    # we finish a layer when inserting the next
+                    if finished_layer is not None:
+                        layer_callback(self, finished_layer)
+                    # notify about end layer, there will not be next
+                    if isEnd:
+                        layer_callback(self, layer_id)
+
         if self.line_class != Line:
             get_line = lambda l: Line(l.raw)
         else:
@@ -422,12 +457,20 @@
                 elif line.command == "M83":
                     relative_e = True
                 elif line.command[0] == "T":
-                    current_tool = int(line.command[1:])
-                    while(current_tool+1>len(self.current_e_multi)):
+                    try:
+                        current_tool = int(line.command[1:])
+                    except:
+                        pass #handle T? by treating it as no tool change
+                    while current_tool+1 > len(self.current_e_multi):
                         self.current_e_multi+=[0]
                         self.offset_e_multi+=[0]
                         self.total_e_multi+=[0]
                         self.max_e_multi+=[0]
+                elif line.command == "M3" or line.command == "M4":
+                    cutting = True
+                elif line.command == "M5":
+                    cutting = False
+
                 current_e_multi = self.current_e_multi[current_tool]
                 offset_e_multi = self.offset_e_multi[current_tool]
                 total_e_multi = self.total_e_multi[current_tool]
@@ -504,6 +547,8 @@
                     elif line.command == "G92":
                         offset_e = current_e - line.e
                         offset_e_multi = current_e_multi - line.e
+                if cutting and self.cutting_as_extrusion:
+                    line.extruding = True
 
                 self.current_e_multi[current_tool]=current_e_multi
                 self.offset_e_multi[current_tool]=offset_e_multi
@@ -516,11 +561,12 @@
                     if line.is_move:
                         if line.extruding:
                             if line.current_x is not None:
-                                xmin_e = min(xmin_e, line.current_x)
-                                xmax_e = max(xmax_e, line.current_x)
+                                # G0 X10 ; G1 X20 E5 results in 10..20 even as G0 is not extruding
+                                xmin_e = min(xmin_e, line.current_x, xmin_e if lastx is None else lastx)
+                                xmax_e = max(xmax_e, line.current_x, xmax_e if lastx is None else lastx)
                             if line.current_y is not None:
-                                ymin_e = min(ymin_e, line.current_y)
-                                ymax_e = max(ymax_e, line.current_y)
+                                ymin_e = min(ymin_e, line.current_y, ymin_e if lasty is None else lasty)
+                                ymax_e = max(ymax_e, line.current_y, ymax_e if lasty is None else lasty)
                         if max_e <= 0:
                             if line.current_x is not None:
                                 xmin = min(xmin, line.current_x)
@@ -531,9 +577,9 @@
 
                     # Compute duration
                     if line.command == "G0" or line.command == "G1":
-                        x = line.x if line.x is not None else lastx
-                        y = line.y if line.y is not None else lasty
-                        z = line.z if line.z is not None else lastz
+                        x = line.x if line.x is not None else (lastx or 0)
+                        y = line.y if line.y is not None else (lasty or 0)
+                        z = line.z if line.z is not None else (lastz or 0)
                         e = line.e if line.e is not None else laste
                         # mm/s vs mm/m => divide by 60
                         f = line.f / 60.0 if line.f is not None else lastf
@@ -553,15 +599,15 @@
                         # The following code tries to fix it by forcing a full
                         # reacceleration if this move is in the opposite direction
                         # of the previous one
-                        dx = x - lastx
-                        dy = y - lasty
+                        dx = x - (lastx or 0)
+                        dy = y - (lasty or 0)
                         if dx * lastdx + dy * lastdy <= 0:
                             lastf = 0
 
                         currenttravel = math.hypot(dx, dy)
                         if currenttravel == 0:
                             if line.z is not None:
-                                currenttravel = abs(line.z) if line.relative else abs(line.z - lastz)
+                                currenttravel = abs(line.z) if line.relative else abs(line.z - (lastz or 0))
                             elif line.e is not None:
                                 currenttravel = abs(line.e) if line.relative_e else abs(line.e - laste)
                         # Feedrate hasn't changed, no acceleration/decceleration planned
@@ -606,47 +652,14 @@
                             else:
                                 cur_z = line.z
 
-                    # FIXME: the logic behind this code seems to work, but it might be
-                    # broken
-                    if cur_z != prev_z:
-                        if prev_z is not None and last_layer_z is not None:
-                            offset = self.est_layer_height if self.est_layer_height else 0.01
-                            if abs(prev_z - last_layer_z) < offset:
-                                if self.est_layer_height is None:
-                                    zs = sorted([l.z for l in all_layers if l.z is not None])
-                                    heights = [round(zs[i + 1] - zs[i], 3) for i in range(len(zs) - 1)]
-                                    heights = [height for height in heights if height]
-                                    if len(heights) >= 2: self.est_layer_height = heights[1]
-                                    elif heights: self.est_layer_height = heights[0]
-                                    else: self.est_layer_height = 0.1
-                                base_z = round(prev_z - (prev_z % self.est_layer_height), 2)
-                            else:
-                                base_z = round(prev_z, 2)
-                        else:
-                            base_z = prev_z
-
-                        if base_z != prev_base_z:
-                            new_layer = Layer(cur_lines, base_z)
-                            new_layer.duration = totalduration - layerbeginduration
-                            layerbeginduration = totalduration
-                            all_layers.append(new_layer)
-                            if cur_layer_has_extrusion and prev_z not in all_zs:
-                                all_zs.add(prev_z)
-                            cur_lines = []
-                            cur_layer_has_extrusion = False
-                            layer_id += 1
-                            layer_line = 0
-                            last_layer_z = base_z
-                            if layer_callback is not None:
-                                layer_callback(self, len(all_layers) - 1)
-
-                        prev_base_z = base_z
+                    if cur_z != prev_z and cur_layer_has_extrusion:
+                        append_lines(cur_lines, False)
+                        all_zs.add(prev_z)
+                        cur_lines = []
+                        cur_layer_has_extrusion = False
 
             if build_layers:
                 cur_lines.append(true_line)
-                layer_idxs.append(layer_id)
-                line_idxs.append(layer_line)
-                layer_line += 1
                 prev_z = cur_z
             # ## Loop done
 
@@ -669,17 +682,14 @@
         self.offset_e_multi[current_tool]=offset_e_multi
         self.max_e_multi[current_tool]=max_e_multi
         self.total_e_multi[current_tool]=total_e_multi
+        self.cutting = cutting
 
 
         # Finalize layers
         if build_layers:
             if cur_lines:
-                new_layer = Layer(cur_lines, prev_z)
-                new_layer.duration = totalduration - layerbeginduration
-                layerbeginduration = totalduration
-                all_layers.append(new_layer)
-                if cur_layer_has_extrusion and prev_z not in all_zs:
-                    all_zs.add(prev_z)
+                append_lines(cur_lines, True)
+                all_zs.add(prev_z)
 
             self.append_layer_id = len(all_layers)
             self.append_layer = Layer([])
@@ -689,7 +699,7 @@
             self.line_idxs = array('I', line_idxs)
 
             # Compute bounding box
-            all_zs = self.all_zs.union(set([zmin])).difference(set([None]))
+            all_zs = self.all_zs.union({zmin}).difference({None})
             zmin = min(all_zs)
             zmax = max(all_zs)
 
@@ -731,25 +741,25 @@
 
 def main():
     if len(sys.argv) < 2:
-        print "usage: %s filename.gcode" % sys.argv[0]
+        print("usage: %s filename.gcode" % sys.argv[0])
         return
 
-    print "Line object size:", sys.getsizeof(Line("G0 X0"))
-    print "Light line object size:", sys.getsizeof(LightLine("G0 X0"))
+    print("Line object size:", sys.getsizeof(Line("G0 X0")))
+    print("Light line object size:", sys.getsizeof(LightLine("G0 X0")))
     gcode = GCode(open(sys.argv[1], "rU"))
 
-    print "Dimensions:"
+    print("Dimensions:")
     xdims = (gcode.xmin, gcode.xmax, gcode.width)
-    print "\tX: %0.02f - %0.02f (%0.02f)" % xdims
+    print("\tX: %0.02f - %0.02f (%0.02f)" % xdims)
     ydims = (gcode.ymin, gcode.ymax, gcode.depth)
-    print "\tY: %0.02f - %0.02f (%0.02f)" % ydims
+    print("\tY: %0.02f - %0.02f (%0.02f)" % ydims)
     zdims = (gcode.zmin, gcode.zmax, gcode.height)
-    print "\tZ: %0.02f - %0.02f (%0.02f)" % zdims
-    print "Filament used: %0.02fmm" % gcode.filament_length
+    print("\tZ: %0.02f - %0.02f (%0.02f)" % zdims)
+    print("Filament used: %0.02fmm" % gcode.filament_length)
     for i in enumerate(gcode.filament_length_multi):
-        print "E%d %0.02fmm" % (i[0],i[1])
-    print "Number of layers: %d" % gcode.layers_count
-    print "Estimated duration: %s" % gcode.estimate_duration()[1]
+        print("E%d %0.02fmm" % (i[0],i[1]))
+    print("Number of layers: %d" % gcode.layers_count)
+    print("Estimated duration: %s" % gcode.estimate_duration()[1])
 
 if __name__ == '__main__':
     main()
--- a/printrun-src/printrun/gcoder_line.pyx	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gcoder_line.pyx	Wed Jan 20 10:15:13 2021 +0100
@@ -1,11 +1,13 @@
-# This file is copied from GCoder.
+#cython: language_level=3
 #
-# GCoder is free software: you can redistribute it and/or modify
+# This file is part of the Printrun suite.
+#
+# Printrun is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
 # the Free Software Foundation, either version 3 of the License, or
 # (at your option) any later version.
 #
-# GCoder is distributed in the hope that it will be useful,
+# Printrun 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 General Public License for more details.
@@ -18,6 +20,7 @@
 from libc.string cimport strlen, strncpy
 
 cdef char* copy_string(object value):
+    value = value.encode('utf-8')
     cdef char* orig = value
     str_len = len(orig)
     cdef char* array = <char *>malloc(str_len + 1)
@@ -192,7 +195,7 @@
             self._status = set_has_var(self._status, pos_gcview_end_vertex)
     property raw:
         def __get__(self):
-            if has_var(self._status, pos_raw): return self._raw
+            if has_var(self._status, pos_raw): return self._raw.decode('utf-8')
             else: return None
         def __set__(self, value):
             # WARNING: memory leak could happen here, as we don't do the following :
@@ -201,7 +204,7 @@
             self._status = set_has_var(self._status, pos_raw)
     property command:
         def __get__(self):
-            if has_var(self._status, pos_command): return self._command
+            if has_var(self._status, pos_command): return self._command.decode('utf-8')
             else: return None
         def __set__(self, value):
             # WARNING: memory leak could happen here, as we don't do the following :
@@ -231,7 +234,7 @@
 
     property raw:
         def __get__(self):
-            if has_var(self._status, pos_raw): return self._raw
+            if has_var(self._status, pos_raw): return self._raw.decode('utf-8')
             else: return None
         def __set__(self, value):
             # WARNING: memory leak could happen here, as we don't do the following :
@@ -240,7 +243,7 @@
             self._status = set_has_var(self._status, pos_raw)
     property command:
         def __get__(self):
-            if has_var(self._status, pos_command): return self._command
+            if has_var(self._status, pos_command): return self._command.decode('utf-8')
             else: return None
         def __set__(self, value):
             # WARNING: memory leak could happen here, as we don't do the following :
--- a/printrun-src/printrun/gcview.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gcview.py	Wed Jan 20 10:15:13 2021 +0100
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 
 # This file is part of the Printrun suite.
 #
@@ -51,8 +51,8 @@
             if hasattr(root, root_fieldname):
                 setattr(model, field, getattr(root, root_fieldname))
 
-def recreate_platform(self, build_dimensions, circular):
-    self.platform = actors.Platform(build_dimensions, circular = circular)
+def recreate_platform(self, build_dimensions, circular, grid):
+    self.platform = actors.Platform(build_dimensions, circular = circular, grid = grid)
     self.objects[0].model = self.platform
     wx.CallAfter(self.Refresh)
 
@@ -66,32 +66,40 @@
             has_changed = True
     return has_changed
 
+# E selected for Up because is above D
+LAYER_UP_KEYS = ord('U'), ord('E'), wx.WXK_UP
+LAYER_DOWN_KEYS = ord('D'), wx.WXK_DOWN
+ZOOM_IN_KEYS = wx.WXK_PAGEDOWN, 388, wx.WXK_RIGHT, ord('=')
+ZOOM_OUT_KEYS = wx.WXK_PAGEUP, 390, wx.WXK_LEFT, ord('-')
+FIT_KEYS = [ord('F')]
+CURRENT_LAYER_KEYS = [ord('C')]
+RESET_KEYS = [ord('R')]
+
 class GcodeViewPanel(wxGLPanel):
 
-    def __init__(self, parent, id = wx.ID_ANY,
-                 build_dimensions = None, realparent = None,
-                 antialias_samples = 0):
-        super(GcodeViewPanel, self).__init__(parent, id, wx.DefaultPosition,
+    def __init__(self, parent,
+                 build_dimensions = (200, 200, 100, 0, 0, 0),
+                 realparent = None, antialias_samples = 0):
+        super().__init__(parent, wx.DefaultPosition,
                                              wx.DefaultSize, 0,
                                              antialias_samples = antialias_samples)
         self.canvas.Bind(wx.EVT_MOUSE_EVENTS, self.move)
         self.canvas.Bind(wx.EVT_LEFT_DCLICK, self.double)
-        self.canvas.Bind(wx.EVT_KEY_DOWN, self.keypress)
+        # self.canvas.Bind(wx.EVT_KEY_DOWN, self.keypress)
+        # in Windows event inspector shows only EVT_CHAR_HOOK events
+        self.canvas.Bind(wx.EVT_CHAR_HOOK, self.keypress)
         self.initialized = 0
         self.canvas.Bind(wx.EVT_MOUSEWHEEL, self.wheel)
-        self.parent = realparent if realparent else parent
+        self.parent = realparent or parent
         self.initpos = None
-        if build_dimensions:
-            self.build_dimensions = build_dimensions
-        else:
-            self.build_dimensions = [200, 200, 100, 0, 0, 0]
-        self.dist = max(self.build_dimensions[0], self.build_dimensions[1])
+        self.build_dimensions = build_dimensions
+        self.dist = max(self.build_dimensions[:2])
         self.basequat = [0, 0, 0, 1]
         self.mousepos = [0, 0]
 
     def inject(self):
         l = self.parent.model.num_layers_to_draw
-        filtered = [k for k, v in self.parent.model.layer_idxs_map.iteritems() if v == l]
+        filtered = [k for k, v in self.parent.model.layer_idxs_map.items() if v == l]
         if filtered:
             injector(self.parent.model.gcode, l, filtered[0])
         else:
@@ -99,7 +107,7 @@
 
     def editlayer(self):
         l = self.parent.model.num_layers_to_draw
-        filtered = [k for k, v in self.parent.model.layer_idxs_map.iteritems() if v == l]
+        filtered = [k for k, v in self.parent.model.layer_idxs_map.items() if v == l]
         if filtered:
             injector_edit(self.parent.model.gcode, l, filtered[0])
         else:
@@ -109,13 +117,13 @@
         pass
 
     def OnInitGL(self, *args, **kwargs):
-        super(GcodeViewPanel, self).OnInitGL(*args, **kwargs)
-        if hasattr(self.parent, "filenames") and self.parent.filenames:
-            for filename in self.parent.filenames:
+        super().OnInitGL(*args, **kwargs)
+        filenames = getattr(self.parent, 'filenames', None)
+        if filenames:
+            for filename in filenames:
                 self.parent.load_file(filename)
             self.parent.autoplate()
-            if hasattr(self.parent, "loadcb"):
-                self.parent.loadcb()
+            getattr(self.parent, 'loadcb', bool)()
             self.parent.filenames = None
 
     def create_objects(self):
@@ -125,7 +133,7 @@
                 obj.model.init()
 
     def update_object_resize(self):
-        '''called when the window recieves only if opengl is initialized'''
+        '''called when the window receives only if opengl is initialized'''
         pass
 
     def draw_objects(self):
@@ -142,9 +150,14 @@
 
         for obj in self.parent.objects:
             if not obj.model \
-               or not obj.model.loaded \
-               or not obj.model.initialized:
+               or not obj.model.loaded:
                 continue
+            # Skip (comment out) initialized check, which safely causes empty 
+            # model during progressive load. This can cause exceptions/garbage
+            # render, but seems fine for now
+            # May need to lock init() and draw_objects() together
+            # if not obj.model.initialized:
+            #     continue
             glPushMatrix()
             glTranslatef(*(obj.offsets))
             glRotatef(obj.rot, 0.0, 0.0, 1.0)
@@ -175,8 +188,7 @@
         return mvmat
 
     def double(self, event):
-        if hasattr(self.parent, "clickcb") and self.parent.clickcb:
-            self.parent.clickcb(event)
+        getattr(self.parent, 'clickcb', bool)(event)
 
     def move(self, event):
         """react to mouse actions:
@@ -188,22 +200,18 @@
             self.canvas.SetFocus()
             event.Skip()
             return
-        if event.Dragging() and event.LeftIsDown():
-            self.handle_rotation(event)
-        elif event.Dragging() and event.RightIsDown():
-            self.handle_translation(event)
-        elif event.LeftUp():
+        if event.Dragging():
+            if event.LeftIsDown():
+                self.handle_rotation(event)
+            elif event.RightIsDown():
+                self.handle_translation(event)
+            self.Refresh(False)
+        elif event.LeftUp() or event.RightUp():
             self.initpos = None
-        elif event.RightUp():
-            self.initpos = None
-        else:
-            event.Skip()
-            return
         event.Skip()
-        wx.CallAfter(self.Refresh)
 
     def layerup(self):
-        if not hasattr(self.parent, "model") or not self.parent.model:
+        if not getattr(self.parent, 'model', False):
             return
         max_layers = self.parent.model.max_layers
         current_layer = self.parent.model.num_layers_to_draw
@@ -216,7 +224,7 @@
         wx.CallAfter(self.Refresh)
 
     def layerdown(self):
-        if not hasattr(self.parent, "model") or not self.parent.model:
+        if not getattr(self.parent, 'model', False):
             return
         current_layer = self.parent.model.num_layers_to_draw
         new_layer = max(1, current_layer - 1)
@@ -224,7 +232,14 @@
         self.parent.setlayercb(new_layer)
         wx.CallAfter(self.Refresh)
 
+    wheelTimestamp = None
     def handle_wheel(self, event):
+        if self.wheelTimestamp == event.Timestamp:
+            # filter duplicate event delivery in Ubuntu, Debian issue #1110
+            return  
+
+        self.wheelTimestamp = event.Timestamp
+
         delta = event.GetWheelRotation()
         factor = 1.05
         if event.ControlDown():
@@ -237,7 +252,7 @@
                 if delta > 0: self.layerup()
                 else: self.layerdown()
             return
-        x, y = event.GetPositionTuple()
+        x, y = event.GetPosition()
         x, y, _ = self.mouse_to_3d(x, y)
         if delta > 0:
             self.zoom(factor, (x, y))
@@ -269,35 +284,34 @@
         wx.CallAfter(self.Refresh)
 
     def keypress(self, event):
-        """gets keypress events and moves/rotates acive shape"""
-        step = 1.1
-        if event.ControlDown():
-            step = 1.05
-        kup = [85, 315]               # Up keys
-        kdo = [68, 317]               # Down Keys
-        kzi = [wx.WXK_PAGEDOWN, 388, 316, 61]        # Zoom In Keys
-        kzo = [wx.WXK_PAGEUP, 390, 314, 45]       # Zoom Out Keys
-        kfit = [70]       # Fit to print keys
-        kshowcurrent = [67]       # Show only current layer keys
-        kreset = [82]       # Reset keys
+        """gets keypress events and moves/rotates active shape"""
+        if event.HasModifiers():
+            # let alt+c bubble up
+            event.Skip()
+            return
+        step = event.ControlDown() and 1.05 or 1.1
         key = event.GetKeyCode()
-        if key in kup:
+        if key in LAYER_UP_KEYS:
             self.layerup()
-        if key in kdo:
+            return  # prevent shifting focus to other controls
+        elif key in LAYER_DOWN_KEYS:
             self.layerdown()
-        x, y, _ = self.mouse_to_3d(self.width / 2, self.height / 2)
-        if key in kzi:
+            return
+        # x, y, _ = self.mouse_to_3d(self.width / 2, self.height / 2)
+        elif key in ZOOM_IN_KEYS:
             self.zoom_to_center(step)
-        if key in kzo:
+            return
+        elif key in ZOOM_OUT_KEYS:
             self.zoom_to_center(1 / step)
-        if key in kfit:
+            return
+        elif key in FIT_KEYS:
             self.fit()
-        if key in kshowcurrent:
+        elif key in CURRENT_LAYER_KEYS:
             if not self.parent.model or not self.parent.model.loaded:
                 return
             self.parent.model.only_current = not self.parent.model.only_current
             wx.CallAfter(self.Refresh)
-        if key in kreset:
+        elif key in RESET_KEYS:
             self.resetview()
         event.Skip()
 
@@ -307,7 +321,7 @@
         self.basequat = [0, 0, 0, 1]
         wx.CallAfter(self.Refresh)
 
-class GCObject(object):
+class GCObject:
 
     def __init__(self, model):
         self.offsets = [0, 0, 0]
@@ -317,7 +331,7 @@
         self.scale = [1.0, 1.0, 1.0]
         self.model = model
 
-class GcodeViewLoader(object):
+class GcodeViewLoader:
 
     path_halfwidth = 0.2
     path_halfheight = 0.15
@@ -332,24 +346,25 @@
             set_model_colors(self.model, self.root)
         if gcode is not None:
             generator = self.model.load_data(gcode)
-            generator_output = generator.next()
+            generator_output = next(generator)
             while generator_output is not None:
                 yield generator_output
-                generator_output = generator.next()
+                generator_output = next(generator)
         wx.CallAfter(self.Refresh)
         yield None
 
     def addfile(self, gcode = None, showall = False):
         generator = self.addfile_perlayer(gcode, showall)
-        while generator.next() is not None:
+        while next(generator) is not None:
             continue
 
     def set_gcview_params(self, path_width, path_height):
         return set_gcview_params(self, path_width, path_height)
 
-class GcodeViewMainWrapper(GcodeViewLoader):
+from printrun.gviz import BaseViz
+class GcodeViewMainWrapper(GcodeViewLoader, BaseViz):
 
-    def __init__(self, parent, build_dimensions, root, circular, antialias_samples):
+    def __init__(self, parent, build_dimensions, root, circular, antialias_samples, grid):
         self.root = root
         self.glpanel = GcodeViewPanel(parent, realparent = self,
                                       build_dimensions = build_dimensions,
@@ -361,13 +376,21 @@
         self.widget = self.glpanel
         self.refresh_timer = wx.CallLater(100, self.Refresh)
         self.p = self  # Hack for backwards compatibility with gviz API
-        self.platform = actors.Platform(build_dimensions, circular = circular)
+        self.grid = grid
+        self.platform = actors.Platform(build_dimensions, circular = circular, grid = grid)
         self.model = None
         self.objects = [GCObject(self.platform), GCObject(None)]
 
     def __getattr__(self, name):
         return getattr(self.glpanel, name)
 
+    def on_settings_change(self, changed_settings):
+        if self.model:
+            for s in changed_settings:
+                if s.name.startswith('gcview_color_'):
+                    self.model.update_colors()
+                    break
+
     def set_current_gline(self, gline):
         if gline.is_move and gline.gcview_end_vertex is not None \
            and self.model and self.model.loaded:
@@ -375,11 +398,8 @@
             if not self.refresh_timer.IsRunning():
                 self.refresh_timer.Start()
 
-    def recreate_platform(self, build_dimensions, circular):
-        return recreate_platform(self, build_dimensions, circular)
-
-    def addgcodehighlight(self, *a):
-        pass
+    def recreate_platform(self, build_dimensions, circular, grid):
+        return recreate_platform(self, build_dimensions, circular, grid)
 
     def setlayer(self, layer):
         if layer in self.model.layer_idxs_map:
@@ -398,7 +418,8 @@
     def __init__(self, parent, ID, title, build_dimensions, objects = None,
                  pos = wx.DefaultPosition, size = wx.DefaultSize,
                  style = wx.DEFAULT_FRAME_STYLE, root = None, circular = False,
-                 antialias_samples = 0):
+                 antialias_samples = 0,
+                 grid = (1, 10)):
         GvizBaseFrame.__init__(self, parent, ID, title,
                                pos, size, style)
         self.root = root
@@ -408,15 +429,12 @@
         self.refresh_timer = wx.CallLater(100, self.Refresh)
         self.p = self  # Hack for backwards compatibility with gviz API
         self.clonefrom = objects
-        self.platform = actors.Platform(build_dimensions, circular = circular)
-        if objects:
-            self.model = objects[1].model
-        else:
-            self.model = None
+        self.platform = actors.Platform(build_dimensions, circular = circular, grid = grid)
+        self.model = objects[1].model if objects else None
         self.objects = [GCObject(self.platform), GCObject(None)]
 
         fit_image = wx.Image(imagefile('fit.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap()
-        self.toolbar.InsertLabelTool(6, 8, " " + _("Fit to plate"), fit_image,
+        self.toolbar.InsertTool(6, 8, " " + _("Fit to plate"), fit_image,
                                      shortHelp = _("Fit to plate [F]"),
                                      longHelp = '')
         self.toolbar.Realize()
@@ -441,7 +459,7 @@
 
     def update_status(self, extra):
         layer = self.model.num_layers_to_draw
-        filtered = [k for k, v in self.model.layer_idxs_map.iteritems() if v == layer]
+        filtered = [k for k, v in self.model.layer_idxs_map.items() if v == layer]
         if filtered:
             true_layer = filtered[0]
             z = self.model.gcode.all_layers[true_layer].z
@@ -465,8 +483,8 @@
             if not self.refresh_timer.IsRunning():
                 self.refresh_timer.Start()
 
-    def recreate_platform(self, build_dimensions, circular):
-        return recreate_platform(self, build_dimensions, circular)
+    def recreate_platform(self, build_dimensions, circular, grid):
+        return recreate_platform(self, build_dimensions, circular, grid)
 
     def addfile(self, gcode = None):
         if self.clonefrom:
--- a/printrun-src/printrun/gl/libtatlin/actors.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gl/libtatlin/actors.py	Wed Jan 20 10:15:13 2021 +0100
@@ -65,7 +65,7 @@
     return [i1, i2, j2, j2, j1, i1, i2, i3, j3, j3, j2, i2,
             i3, i4, j4, j4, j3, i3, i4, i1, j1, j1, j4, i4]
 
-class BoundingBox(object):
+class BoundingBox:
     """
     A rectangular box (cuboid) enclosing a 3D model, defined by lower and upper corners.
     """
@@ -89,13 +89,12 @@
         return round(height, 2)
 
 
-class Platform(object):
+class Platform:
     """
     Platform on which models are placed.
     """
-    graduations_major = 10
 
-    def __init__(self, build_dimensions, light = False, circular = False):
+    def __init__(self, build_dimensions, light = False, circular = False, grid = (1, 10)):
         self.light = light
         self.circular = circular
         self.width = build_dimensions[0]
@@ -104,6 +103,7 @@
         self.xoffset = build_dimensions[3]
         self.yoffset = build_dimensions[4]
         self.zoffset = build_dimensions[5]
+        self.grid = grid
 
         self.color_grads_minor = (0xaf / 255, 0xdf / 255, 0x5f / 255, 0.1)
         self.color_grads_interm = (0xaf / 255, 0xdf / 255, 0x5f / 255, 0.2)
@@ -122,9 +122,9 @@
         glTranslatef(self.xoffset, self.yoffset, self.zoffset)
 
         def color(i):
-            if i % self.graduations_major == 0:
+            if i % self.grid[1] == 0:
                 glColor4f(*self.color_grads_major)
-            elif i % (self.graduations_major / 2) == 0:
+            elif i % (self.grid[1] // 2) == 0:
                 glColor4f(*self.color_grads_interm)
             else:
                 if self.light: return False
@@ -134,26 +134,26 @@
         # draw the grid
         glBegin(GL_LINES)
         if self.circular:  # Draw a circular grid
-            for i in range(0, int(math.ceil(self.width + 1))):
+            for i in numpy.arange(0, int(math.ceil(self.width + 1)), self.grid[0]):
                 angle = math.asin(2 * float(i) / self.width - 1)
                 x = (math.cos(angle) + 1) * self.depth / 2
                 if color(i):
                     glVertex3f(float(i), self.depth - x, 0.0)
                     glVertex3f(float(i), x, 0.0)
 
-            for i in range(0, int(math.ceil(self.depth + 1))):
+            for i in numpy.arange(0, int(math.ceil(self.depth + 1)), self.grid[0]):
                 angle = math.acos(2 * float(i) / self.depth - 1)
                 x = (math.sin(angle) + 1) * self.width / 2
                 if color(i):
                     glVertex3f(self.width - x, float(i), 0.0)
                     glVertex3f(x, float(i), 0.0)
         else:  # Draw a rectangular grid
-            for i in range(0, int(math.ceil(self.width + 1))):
+            for i in numpy.arange(0, int(math.ceil(self.width + 1)), self.grid[0]):
                 if color(i):
                     glVertex3f(float(i), 0.0, 0.0)
                     glVertex3f(float(i), self.depth, 0.0)
 
-            for i in range(0, int(math.ceil(self.depth + 1))):
+            for i in numpy.arange(0, int(math.ceil(self.depth + 1)), self.grid[0]):
                 if color(i):
                     glVertex3f(0, float(i), 0.0)
                     glVertex3f(self.width, float(i), 0.0)
@@ -174,7 +174,7 @@
         # glCallList(self.display_list)
         self.draw()
 
-class PrintHead(object):
+class PrintHead:
     def __init__(self):
         self.color = (43. / 255, 0., 175. / 255, 1.0)
         self.scale = 5
@@ -209,7 +209,7 @@
         glLineWidth(orig_linewidth)
         glDisable(GL_LINE_SMOOTH)
 
-class Model(object):
+class Model:
     """
     Parent class for models that provides common functionality.
     """
@@ -315,6 +315,47 @@
         gline_idx = 0
     return None
 
+def interpolate_arcs(gline, prev_gline):
+    if gline.command == "G2" or gline.command == "G3":
+        rx = gline.i if gline.i is not None else 0
+        ry = gline.j if gline.j is not None else 0
+        r = math.sqrt(rx*rx + ry*ry)
+
+        cx = prev_gline.current_x + rx
+        cy = prev_gline.current_y + ry
+
+        a_start = math.atan2(-ry, -rx)
+        dx = gline.current_x - cx
+        dy = gline.current_y - cy
+        a_end = math.atan2(dy, dx)
+        a_delta = a_end - a_start
+
+        if gline.command == "G3" and a_delta <= 0:
+            a_delta += math.pi * 2
+        elif gline.command == "G2" and a_delta >= 0:
+            a_delta -= math.pi * 2
+
+        z0 = prev_gline.current_z
+        dz = gline.current_z - z0
+
+        # max segment size: 0.5mm, max num of segments: 100
+        segments = math.ceil(abs(a_delta) * r * 2 / 0.5)
+        if segments > 100:
+            segments = 100
+
+        for t in range(segments):
+            a = t / segments * a_delta + a_start
+
+            mid = (
+                cx + math.cos(a) * r,
+                cy + math.sin(a) * r,
+                z0 + t / segments * dz
+            )
+            yield mid
+
+    yield (gline.current_x, gline.current_y, gline.current_z)
+
+
 class GcodeModel(Model):
     """
     Model for displaying Gcode data.
@@ -363,6 +404,7 @@
         # to store coordinates/colors/normals.
         # Nicely enough we have 3 per kind of thing for all kinds.
         coordspervertex = 3
+        buffered_color_len = 3  # 4th color component (alpha) is ignored
         verticesperline = 8
         coordsperline = coordspervertex * verticesperline
         coords_count = lambda nlines: nlines * coordsperline
@@ -389,20 +431,19 @@
         vertices = self.vertices = numpy.zeros(ncoords, dtype = GLfloat)
         vertex_k = 0
         colors = self.colors = numpy.zeros(ncoords, dtype = GLfloat)
+
         color_k = 0
         normals = self.normals = numpy.zeros(ncoords, dtype = GLfloat)
-        normal_k = 0
         indices = self.indices = numpy.zeros(nindices, dtype = GLuint)
         index_k = 0
         self.layer_idxs_map = {}
         self.layer_stops = [0]
 
-        prev_is_extruding = False
         prev_move_normal_x = None
         prev_move_normal_y = None
         prev_move_angle = None
-
         prev_pos = (0, 0, 0)
+        prev_gline = None
         layer_idx = 0
 
         self.printed_until = 0
@@ -435,83 +476,122 @@
                     if gline.x is None and gline.y is None and gline.z is None:
                         continue
                     has_movement = True
-                    current_pos = (gline.current_x, gline.current_y, gline.current_z)
-                    if not gline.extruding:
-                        travel_vertices[travel_vertex_k] = prev_pos[0]
-                        travel_vertices[travel_vertex_k + 1] = prev_pos[1]
-                        travel_vertices[travel_vertex_k + 2] = prev_pos[2]
-                        travel_vertices[travel_vertex_k + 3] = current_pos[0]
-                        travel_vertices[travel_vertex_k + 4] = current_pos[1]
-                        travel_vertices[travel_vertex_k + 5] = current_pos[2]
-                        travel_vertex_k += 6
-                        prev_is_extruding = False
-                    else:
-                        gline_color = self.movement_color(gline)
+                    for current_pos in interpolate_arcs(gline, prev_gline):
+                        if not gline.extruding:
+                            if self.travels.size < (travel_vertex_k + 100 * 6):
+                                # arc interpolation extra points allocation
+                                # if not enough room for another 100 points now,
+                                # allocate enough and 50% extra to minimize separate allocations
+                                ratio = (travel_vertex_k + 100 * 6) / self.travels.size * 1.5
+                                # print(f"gl realloc travel {self.travels.size} -> {int(self.travels.size * ratio)}")
+                                self.travels.resize(int(self.travels.size * ratio), refcheck = False)
 
-                        next_move = get_next_move(model_data, layer_idx, gline_idx)
-                        next_is_extruding = (next_move.extruding
-                                             if next_move is not None else False)
+                            travel_vertices[travel_vertex_k:travel_vertex_k+3] = prev_pos
+                            travel_vertices[travel_vertex_k + 3:travel_vertex_k + 6] = current_pos
+                            travel_vertex_k += 6
+                        else:
+                            delta_x = current_pos[0] - prev_pos[0]
+                            delta_y = current_pos[1] - prev_pos[1]
+                            norm = delta_x * delta_x + delta_y * delta_y
+                            if norm == 0:  # Don't draw anything if this move is Z+E only
+                                continue
+                            norm = math.sqrt(norm)
+                            move_normal_x = - delta_y / norm
+                            move_normal_y = delta_x / norm
+                            move_angle = math.atan2(delta_y, delta_x)
 
-                        delta_x = current_pos[0] - prev_pos[0]
-                        delta_y = current_pos[1] - prev_pos[1]
-                        norm = delta_x * delta_x + delta_y * delta_y
-                        if norm == 0:  # Don't draw anything if this move is Z+E only
-                            continue
-                        norm = math.sqrt(norm)
-                        move_normal_x = - delta_y / norm
-                        move_normal_y = delta_x / norm
-                        move_angle = math.atan2(delta_y, delta_x)
-
-                        # FIXME: compute these dynamically
-                        path_halfwidth = self.path_halfwidth * 1.2
-                        path_halfheight = self.path_halfheight * 1.2
+                            # FIXME: compute these dynamically
+                            path_halfwidth = self.path_halfwidth * 1.2
+                            path_halfheight = self.path_halfheight * 1.2
 
-                        new_indices = []
-                        new_vertices = []
-                        new_normals = []
-                        if prev_is_extruding:
-                            # Store previous vertices indices
-                            prev_id = vertex_k / 3 - 4
-                            avg_move_normal_x = (prev_move_normal_x + move_normal_x) / 2
-                            avg_move_normal_y = (prev_move_normal_y + move_normal_y) / 2
-                            norm = avg_move_normal_x * avg_move_normal_x + avg_move_normal_y * avg_move_normal_y
-                            if norm == 0:
-                                avg_move_normal_x = move_normal_x
-                                avg_move_normal_y = move_normal_y
+                            new_indices = []
+                            new_vertices = []
+                            new_normals = []
+                            if prev_gline and prev_gline.extruding:
+                                # Store previous vertices indices
+                                prev_id = vertex_k // 3 - 4
+                                avg_move_normal_x = (prev_move_normal_x + move_normal_x) / 2
+                                avg_move_normal_y = (prev_move_normal_y + move_normal_y) / 2
+                                norm = avg_move_normal_x * avg_move_normal_x + avg_move_normal_y * avg_move_normal_y
+                                if norm == 0:
+                                    avg_move_normal_x = move_normal_x
+                                    avg_move_normal_y = move_normal_y
+                                else:
+                                    norm = math.sqrt(norm)
+                                    avg_move_normal_x /= norm
+                                    avg_move_normal_y /= norm
+                                delta_angle = move_angle - prev_move_angle
+                                delta_angle = (delta_angle + twopi) % twopi
+                                fact = abs(math.cos(delta_angle / 2))
+                                # If move is turning too much, avoid creating a big peak
+                                # by adding an intermediate box
+                                if fact < 0.5:
+                                    # FIXME: It looks like there's some heavy code duplication here...
+                                    hw = path_halfwidth
+                                    p1x = prev_pos[0] - hw * prev_move_normal_x
+                                    p2x = prev_pos[0] + hw * prev_move_normal_x
+                                    p1y = prev_pos[1] - hw * prev_move_normal_y
+                                    p2y = prev_pos[1] + hw * prev_move_normal_y
+                                    new_vertices.extend((prev_pos[0], prev_pos[1], prev_pos[2] + path_halfheight))
+                                    new_vertices.extend((p1x, p1y, prev_pos[2]))
+                                    new_vertices.extend((prev_pos[0], prev_pos[1], prev_pos[2] - path_halfheight))
+                                    new_vertices.extend((p2x, p2y, prev_pos[2]))
+                                    new_normals.extend((0, 0, 1))
+                                    new_normals.extend((-prev_move_normal_x, -prev_move_normal_y, 0))
+                                    new_normals.extend((0, 0, -1))
+                                    new_normals.extend((prev_move_normal_x, prev_move_normal_y, 0))
+                                    first = vertex_k // 3
+                                    # Link to previous
+                                    new_indices += triangulate_box(prev_id, prev_id + 1,
+                                                                prev_id + 2, prev_id + 3,
+                                                                first, first + 1,
+                                                                first + 2, first + 3)
+                                    p1x = prev_pos[0] - hw * move_normal_x
+                                    p2x = prev_pos[0] + hw * move_normal_x
+                                    p1y = prev_pos[1] - hw * move_normal_y
+                                    p2y = prev_pos[1] + hw * move_normal_y
+                                    new_vertices.extend((prev_pos[0], prev_pos[1], prev_pos[2] + path_halfheight))
+                                    new_vertices.extend((p1x, p1y, prev_pos[2]))
+                                    new_vertices.extend((prev_pos[0], prev_pos[1], prev_pos[2] - path_halfheight))
+                                    new_vertices.extend((p2x, p2y, prev_pos[2]))
+                                    new_normals.extend((0, 0, 1))
+                                    new_normals.extend((-move_normal_x, -move_normal_y, 0))
+                                    new_normals.extend((0, 0, -1))
+                                    new_normals.extend((move_normal_x, move_normal_y, 0))
+                                    prev_id += 4
+                                    first += 4
+                                    # Link to previous
+                                    new_indices += triangulate_box(prev_id, prev_id + 1,
+                                                                prev_id + 2, prev_id + 3,
+                                                                first, first + 1,
+                                                                first + 2, first + 3)
+                                else:
+                                    hw = path_halfwidth / fact
+                                    # Compute vertices
+                                    p1x = prev_pos[0] - hw * avg_move_normal_x
+                                    p2x = prev_pos[0] + hw * avg_move_normal_x
+                                    p1y = prev_pos[1] - hw * avg_move_normal_y
+                                    p2y = prev_pos[1] + hw * avg_move_normal_y
+                                    new_vertices.extend((prev_pos[0], prev_pos[1], prev_pos[2] + path_halfheight))
+                                    new_vertices.extend((p1x, p1y, prev_pos[2]))
+                                    new_vertices.extend((prev_pos[0], prev_pos[1], prev_pos[2] - path_halfheight))
+                                    new_vertices.extend((p2x, p2y, prev_pos[2]))
+                                    new_normals.extend((0, 0, 1))
+                                    new_normals.extend((-avg_move_normal_x, -avg_move_normal_y, 0))
+                                    new_normals.extend((0, 0, -1))
+                                    new_normals.extend((avg_move_normal_x, avg_move_normal_y, 0))
+                                    first = vertex_k // 3
+                                    # Link to previous
+                                    new_indices += triangulate_box(prev_id, prev_id + 1,
+                                                                prev_id + 2, prev_id + 3,
+                                                                first, first + 1,
+                                                                first + 2, first + 3)
                             else:
-                                norm = math.sqrt(norm)
-                                avg_move_normal_x /= norm
-                                avg_move_normal_y /= norm
-                            delta_angle = move_angle - prev_move_angle
-                            delta_angle = (delta_angle + twopi) % twopi
-                            fact = abs(math.cos(delta_angle / 2))
-                            # If move is turning too much, avoid creating a big peak
-                            # by adding an intermediate box
-                            if fact < 0.5:
-                                # FIXME: It looks like there's some heavy code duplication here...
-                                hw = path_halfwidth
-                                p1x = prev_pos[0] - hw * prev_move_normal_x
-                                p2x = prev_pos[0] + hw * prev_move_normal_x
-                                p1y = prev_pos[1] - hw * prev_move_normal_y
-                                p2y = prev_pos[1] + hw * prev_move_normal_y
-                                new_vertices.extend((prev_pos[0], prev_pos[1], prev_pos[2] + path_halfheight))
-                                new_vertices.extend((p1x, p1y, prev_pos[2]))
-                                new_vertices.extend((prev_pos[0], prev_pos[1], prev_pos[2] - path_halfheight))
-                                new_vertices.extend((p2x, p2y, prev_pos[2]))
-                                new_normals.extend((0, 0, 1))
-                                new_normals.extend((-prev_move_normal_x, -prev_move_normal_y, 0))
-                                new_normals.extend((0, 0, -1))
-                                new_normals.extend((prev_move_normal_x, prev_move_normal_y, 0))
-                                first = vertex_k / 3
-                                # Link to previous
-                                new_indices += triangulate_box(prev_id, prev_id + 1,
-                                                               prev_id + 2, prev_id + 3,
-                                                               first, first + 1,
-                                                               first + 2, first + 3)
-                                p1x = prev_pos[0] - hw * move_normal_x
-                                p2x = prev_pos[0] + hw * move_normal_x
-                                p1y = prev_pos[1] - hw * move_normal_y
-                                p2y = prev_pos[1] + hw * move_normal_y
+                                # Compute vertices normal to the current move and cap it
+                                p1x = prev_pos[0] - path_halfwidth * move_normal_x
+                                p2x = prev_pos[0] + path_halfwidth * move_normal_x
+                                p1y = prev_pos[1] - path_halfwidth * move_normal_y
+                                p2y = prev_pos[1] + path_halfwidth * move_normal_y
                                 new_vertices.extend((prev_pos[0], prev_pos[1], prev_pos[2] + path_halfheight))
                                 new_vertices.extend((p1x, p1y, prev_pos[2]))
                                 new_vertices.extend((prev_pos[0], prev_pos[1], prev_pos[2] - path_halfheight))
@@ -520,97 +600,68 @@
                                 new_normals.extend((-move_normal_x, -move_normal_y, 0))
                                 new_normals.extend((0, 0, -1))
                                 new_normals.extend((move_normal_x, move_normal_y, 0))
-                                prev_id += 4
-                                first += 4
-                                # Link to previous
-                                new_indices += triangulate_box(prev_id, prev_id + 1,
-                                                               prev_id + 2, prev_id + 3,
-                                                               first, first + 1,
-                                                               first + 2, first + 3)
-                            else:
-                                hw = path_halfwidth / fact
-                                # Compute vertices
-                                p1x = prev_pos[0] - hw * avg_move_normal_x
-                                p2x = prev_pos[0] + hw * avg_move_normal_x
-                                p1y = prev_pos[1] - hw * avg_move_normal_y
-                                p2y = prev_pos[1] + hw * avg_move_normal_y
-                                new_vertices.extend((prev_pos[0], prev_pos[1], prev_pos[2] + path_halfheight))
-                                new_vertices.extend((p1x, p1y, prev_pos[2]))
-                                new_vertices.extend((prev_pos[0], prev_pos[1], prev_pos[2] - path_halfheight))
-                                new_vertices.extend((p2x, p2y, prev_pos[2]))
+                                first = vertex_k // 3
+                                new_indices = triangulate_rectangle(first, first + 1,
+                                                                    first + 2, first + 3)
+
+                            next_move = get_next_move(model_data, layer_idx, gline_idx)
+                            next_is_extruding = next_move and next_move.extruding
+                            if not next_is_extruding:
+                                # Compute caps and link everything
+                                p1x = current_pos[0] - path_halfwidth * move_normal_x
+                                p2x = current_pos[0] + path_halfwidth * move_normal_x
+                                p1y = current_pos[1] - path_halfwidth * move_normal_y
+                                p2y = current_pos[1] + path_halfwidth * move_normal_y
+                                new_vertices.extend((current_pos[0], current_pos[1], current_pos[2] + path_halfheight))
+                                new_vertices.extend((p1x, p1y, current_pos[2]))
+                                new_vertices.extend((current_pos[0], current_pos[1], current_pos[2] - path_halfheight))
+                                new_vertices.extend((p2x, p2y, current_pos[2]))
                                 new_normals.extend((0, 0, 1))
-                                new_normals.extend((-avg_move_normal_x, -avg_move_normal_y, 0))
+                                new_normals.extend((-move_normal_x, -move_normal_y, 0))
                                 new_normals.extend((0, 0, -1))
-                                new_normals.extend((avg_move_normal_x, avg_move_normal_y, 0))
-                                first = vertex_k / 3
-                                # Link to previous
-                                new_indices += triangulate_box(prev_id, prev_id + 1,
-                                                               prev_id + 2, prev_id + 3,
-                                                               first, first + 1,
-                                                               first + 2, first + 3)
-                        else:
-                            # Compute vertices normal to the current move and cap it
-                            p1x = prev_pos[0] - path_halfwidth * move_normal_x
-                            p2x = prev_pos[0] + path_halfwidth * move_normal_x
-                            p1y = prev_pos[1] - path_halfwidth * move_normal_y
-                            p2y = prev_pos[1] + path_halfwidth * move_normal_y
-                            new_vertices.extend((prev_pos[0], prev_pos[1], prev_pos[2] + path_halfheight))
-                            new_vertices.extend((p1x, p1y, prev_pos[2]))
-                            new_vertices.extend((prev_pos[0], prev_pos[1], prev_pos[2] - path_halfheight))
-                            new_vertices.extend((p2x, p2y, prev_pos[2]))
-                            new_normals.extend((0, 0, 1))
-                            new_normals.extend((-move_normal_x, -move_normal_y, 0))
-                            new_normals.extend((0, 0, -1))
-                            new_normals.extend((move_normal_x, move_normal_y, 0))
-                            first = vertex_k / 3
-                            new_indices = triangulate_rectangle(first, first + 1,
-                                                                first + 2, first + 3)
+                                new_normals.extend((move_normal_x, move_normal_y, 0))
+                                end_first = vertex_k // 3 + len(new_vertices) // 3 - 4
+                                new_indices += triangulate_rectangle(end_first + 3, end_first + 2,
+                                                                    end_first + 1, end_first)
+                                new_indices += triangulate_box(first, first + 1,
+                                                            first + 2, first + 3,
+                                                            end_first, end_first + 1,
+                                                            end_first + 2, end_first + 3)
 
-                        if not next_is_extruding:
-                            # Compute caps and link everything
-                            p1x = current_pos[0] - path_halfwidth * move_normal_x
-                            p2x = current_pos[0] + path_halfwidth * move_normal_x
-                            p1y = current_pos[1] - path_halfwidth * move_normal_y
-                            p2y = current_pos[1] + path_halfwidth * move_normal_y
-                            new_vertices.extend((current_pos[0], current_pos[1], current_pos[2] + path_halfheight))
-                            new_vertices.extend((p1x, p1y, current_pos[2]))
-                            new_vertices.extend((current_pos[0], current_pos[1], current_pos[2] - path_halfheight))
-                            new_vertices.extend((p2x, p2y, current_pos[2]))
-                            new_normals.extend((0, 0, 1))
-                            new_normals.extend((-move_normal_x, -move_normal_y, 0))
-                            new_normals.extend((0, 0, -1))
-                            new_normals.extend((move_normal_x, move_normal_y, 0))
-                            end_first = vertex_k / 3 + len(new_vertices) / 3 - 4
-                            new_indices += triangulate_rectangle(end_first + 3, end_first + 2,
-                                                                 end_first + 1, end_first)
-                            new_indices += triangulate_box(first, first + 1,
-                                                           first + 2, first + 3,
-                                                           end_first, end_first + 1,
-                                                           end_first + 2, end_first + 3)
+                            if self.indices.size < (index_k + len(new_indices) + 100 * indicesperline):
+                                # arc interpolation extra points allocation
+                                ratio = (index_k + len(new_indices) + 100 * indicesperline) / self.indices.size * 1.5
+                                # print(f"gl realloc print {self.vertices.size} -> {int(self.vertices.size * ratio)}")
+                                self.vertices.resize(int(self.vertices.size * ratio), refcheck = False)
+                                self.colors.resize(int(self.colors.size * ratio), refcheck = False)
+                                self.normals.resize(int(self.normals.size * ratio), refcheck = False)
+                                self.indices.resize(int(self.indices.size * ratio), refcheck = False)
+
+                            for new_i, item in enumerate(new_indices):
+                                indices[index_k + new_i] = item
+                            index_k += len(new_indices)
 
-                        for new_i, item in enumerate(new_indices):
-                            indices[index_k + new_i] = item
-                        index_k += len(new_indices)
-                        for new_i, item in enumerate(new_vertices):
-                            vertices[vertex_k + new_i] = item
-                        vertex_k += len(new_vertices)
-                        for new_i, item in enumerate(new_normals):
-                            normals[normal_k + new_i] = item
-                        normal_k += len(new_normals)
-                        new_colors = list(gline_color)[:-1] * (len(new_vertices) / 3)
-                        for new_i, item in enumerate(new_colors):
-                            colors[color_k + new_i] = item
-                        color_k += len(new_colors)
+                            new_vertices_len = len(new_vertices)
+                            vertices[vertex_k:vertex_k+new_vertices_len] = new_vertices
+                            normals[vertex_k:vertex_k+new_vertices_len] = new_normals
+                            vertex_k += new_vertices_len
 
-                        prev_is_extruding = True
-                        prev_move_normal_x = move_normal_x
-                        prev_move_normal_y = move_normal_y
-                        prev_move_angle = move_angle
+                            new_vertices_count = new_vertices_len//coordspervertex
+                            # settings support alpha (transperancy), but it is ignored here
+                            gline_color = self.movement_color(gline)[:buffered_color_len]
+                            for vi in range(new_vertices_count):
+                                colors[color_k:color_k+buffered_color_len] = gline_color
+                                color_k += buffered_color_len
 
-                    prev_pos = current_pos
-                    count_travel_indices.append(travel_vertex_k / 3)
+                            prev_move_normal_x = move_normal_x
+                            prev_move_normal_y = move_normal_y
+                            prev_move_angle = move_angle
+
+                        prev_pos = current_pos
+                    prev_gline = gline
+                    count_travel_indices.append(travel_vertex_k // 3)
                     count_print_indices.append(index_k)
-                    count_print_vertices.append(vertex_k / 3)
+                    count_print_vertices.append(vertex_k // 3)
                     gline.gcview_end_vertex = len(count_print_indices) - 1
 
                 if has_movement:
@@ -637,7 +688,7 @@
             self.travels.resize(travel_vertex_k, refcheck = False)
             self.vertices.resize(vertex_k, refcheck = False)
             self.colors.resize(color_k, refcheck = False)
-            self.normals.resize(normal_k, refcheck = False)
+            self.normals.resize(vertex_k, refcheck = False)
             self.indices.resize(index_k, refcheck = False)
 
             self.layer_stops = array.array('L', self.layer_stops)
@@ -655,7 +706,7 @@
         t_end = time.time()
 
         logging.debug(_('Initialized 3D visualization in %.2f seconds') % (t_end - t_start))
-        logging.debug(_('Vertex count: %d') % ((len(self.vertices) + len(self.travels)) / 3))
+        logging.debug(_('Vertex count: %d') % ((len(self.vertices) + len(self.travels)) // 3))
         yield None
 
     def copy(self):
@@ -673,6 +724,24 @@
         copy.initialized = False
         return copy
 
+    def update_colors(self):
+        """Rebuild gl color buffer without loading. Used after color settings edit"""
+        ncoords = self.count_print_vertices[-1]
+        colors = numpy.empty(ncoords*3, dtype = GLfloat)
+        cur_vertex = 0
+        gline_i = 1
+        for gline in self.gcode.lines:
+            if gline.gcview_end_vertex:
+                gline_color = self.movement_color(gline)[:3]
+                last_vertex = self.count_print_vertices[gline_i]
+                gline_i += 1
+                while cur_vertex < last_vertex:
+                    colors[cur_vertex*3:cur_vertex*3+3] = gline_color
+                    cur_vertex += 1
+        if self.vertex_color_buffer:
+            self.vertex_color_buffer.delete()
+        self.vertex_color_buffer = numpy2vbo(colors, use_vbos = self.use_vbos)
+
     # ------------------------------------------------------------------------
     # DRAWING
     # ------------------------------------------------------------------------
@@ -869,10 +938,11 @@
         color_k = 0
         self.printed_until = -1
         self.only_current = False
+        prev_gline = None
         while layer_idx < len(model_data.all_layers):
             with self.lock:
                 nlines = len(model_data)
-                if nlines * 6 != vertices.size:
+                if nlines * 6 > vertices.size:
                     self.vertices.resize(nlines * 6, refcheck = False)
                     self.colors.resize(nlines * 8, refcheck = False)
                 layer = model_data.all_layers[layer_idx]
@@ -882,32 +952,43 @@
                         continue
                     if gline.x is None and gline.y is None and gline.z is None:
                         continue
+
                     has_movement = True
-                    vertices[vertex_k] = prev_pos[0]
-                    vertices[vertex_k + 1] = prev_pos[1]
-                    vertices[vertex_k + 2] = prev_pos[2]
-                    current_pos = (gline.current_x, gline.current_y, gline.current_z)
-                    vertices[vertex_k + 3] = current_pos[0]
-                    vertices[vertex_k + 4] = current_pos[1]
-                    vertices[vertex_k + 5] = current_pos[2]
-                    vertex_k += 6
+                    for current_pos in interpolate_arcs(gline, prev_gline):
+
+                        if self.vertices.size < (vertex_k + 100 * 6):
+                            # arc interpolation extra points allocation
+                            ratio = (vertex_k + 100 * 6) / self.vertices.size * 1.5
+                            # print(f"gl realloc lite {self.vertices.size} -> {int(self.vertices.size * ratio)}")
+                            self.vertices.resize(int(self.vertices.size * ratio), refcheck = False)
+                            self.colors.resize(int(self.colors.size * ratio), refcheck = False)
+
 
-                    vertex_color = self.movement_color(gline)
-                    colors[color_k] = vertex_color[0]
-                    colors[color_k + 1] = vertex_color[1]
-                    colors[color_k + 2] = vertex_color[2]
-                    colors[color_k + 3] = vertex_color[3]
-                    colors[color_k + 4] = vertex_color[0]
-                    colors[color_k + 5] = vertex_color[1]
-                    colors[color_k + 6] = vertex_color[2]
-                    colors[color_k + 7] = vertex_color[3]
-                    color_k += 8
+                        vertices[vertex_k] = prev_pos[0]
+                        vertices[vertex_k + 1] = prev_pos[1]
+                        vertices[vertex_k + 2] = prev_pos[2]
+                        vertices[vertex_k + 3] = current_pos[0]
+                        vertices[vertex_k + 4] = current_pos[1]
+                        vertices[vertex_k + 5] = current_pos[2]
+                        vertex_k += 6
 
-                    prev_pos = current_pos
-                    gline.gcview_end_vertex = vertex_k / 3
+                        vertex_color = self.movement_color(gline)
+                        colors[color_k] = vertex_color[0]
+                        colors[color_k + 1] = vertex_color[1]
+                        colors[color_k + 2] = vertex_color[2]
+                        colors[color_k + 3] = vertex_color[3]
+                        colors[color_k + 4] = vertex_color[0]
+                        colors[color_k + 5] = vertex_color[1]
+                        colors[color_k + 6] = vertex_color[2]
+                        colors[color_k + 7] = vertex_color[3]
+                        color_k += 8
+
+                        prev_pos = current_pos
+                        prev_gline = gline
+                        gline.gcview_end_vertex = vertex_k // 3
 
                 if has_movement:
-                    self.layer_stops.append(vertex_k / 3)
+                    self.layer_stops.append(vertex_k // 3)
                     self.layer_idxs_map[layer_idx] = len(self.layer_stops) - 1
                     self.max_layers = len(self.layer_stops) - 1
                     self.num_layers_to_draw = self.max_layers + 1
@@ -936,7 +1017,7 @@
         t_end = time.time()
 
         logging.debug(_('Initialized 3D visualization in %.2f seconds') % (t_end - t_start))
-        logging.debug(_('Vertex count: %d') % (len(self.vertices) / 3))
+        logging.debug(_('Vertex count: %d') % (len(self.vertices) // 3))
         yield None
 
     def copy(self):
--- a/printrun-src/printrun/gl/panel.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gl/panel.py	Wed Jan 20 10:15:13 2021 +0100
@@ -1,5 +1,3 @@
-#!/usr/bin/env python
-
 # This file is part of the Printrun suite.
 #
 # Printrun is free software: you can redistribute it and/or modify
@@ -39,81 +37,136 @@
     GL_MODELVIEW_MATRIX, GL_ONE_MINUS_SRC_ALPHA, glOrtho, \
     GL_PROJECTION, GL_PROJECTION_MATRIX, glScalef, \
     GL_SRC_ALPHA, glTranslatef, gluPerspective, gluUnProject, \
-    glViewport, GL_VIEWPORT
+    glViewport, GL_VIEWPORT, glPushMatrix, glPopMatrix, \
+    glBegin, glVertex2f, glVertex3f, glEnd, GL_LINE_LOOP, glColor3f, \
+    GL_LINE_STIPPLE, glColor4f, glLineStipple
+
 from pyglet import gl
-from .trackball import trackball, mulquat
+from .trackball import trackball, mulquat, axis_to_quat
 from .libtatlin.actors import vec
+from pyglet.gl.glu import gluOrtho2D
 
-class wxGLPanel(wx.Panel):
+# When Subclassing wx.Window in Windows the focus goes to the wx.Window
+# instead of GLCanvas and it does not draw the focus rectangle and
+# does not consume used keystrokes
+# BASE_CLASS = wx.Window
+# Subclassing Panel solves problem In Windows
+BASE_CLASS = wx.Panel
+# BASE_CLASS = wx.ScrolledWindow
+# BASE_CLASS = glcanvas.GLCanvas
+class wxGLPanel(BASE_CLASS):
     '''A simple class for using OpenGL with wxPython.'''
 
+    orbit_control = True
     orthographic = True
     color_background = (0.98, 0.98, 0.78, 1)
     do_lights = True
 
-    def __init__(self, parent, id, pos = wx.DefaultPosition,
+    def __init__(self, parent, pos = wx.DefaultPosition,
                  size = wx.DefaultSize, style = 0,
                  antialias_samples = 0):
-        # Forcing a no full repaint to stop flickering
-        style = style | wx.NO_FULL_REPAINT_ON_RESIZE
-        super(wxGLPanel, self).__init__(parent, id, pos, size, style)
+        # Full repaint should not be a performance problem
+        #TODO: test on windows, tested in Ubuntu
+        style = style | wx.FULL_REPAINT_ON_RESIZE
 
         self.GLinitialized = False
         self.mview_initialized = False
-        attribList = (glcanvas.WX_GL_RGBA,  # RGBA
+        attribList = [glcanvas.WX_GL_RGBA,  # RGBA
                       glcanvas.WX_GL_DOUBLEBUFFER,  # Double Buffered
-                      glcanvas.WX_GL_DEPTH_SIZE, 24)  # 24 bit
+                      glcanvas.WX_GL_DEPTH_SIZE, 24  # 24 bit
+                      ]
 
         if antialias_samples > 0 and hasattr(glcanvas, "WX_GL_SAMPLE_BUFFERS"):
             attribList += (glcanvas.WX_GL_SAMPLE_BUFFERS, 1,
                            glcanvas.WX_GL_SAMPLES, antialias_samples)
 
-        self.width = None
-        self.height = None
+        attribList.append(0)
 
-        self.sizer = wx.BoxSizer(wx.HORIZONTAL)
-        self.canvas = glcanvas.GLCanvas(self, attribList = attribList)
+        if BASE_CLASS is glcanvas.GLCanvas:
+            super().__init__(parent, wx.ID_ANY, attribList, pos, size, style)
+            self.canvas = self
+        else:
+            super().__init__(parent, wx.ID_ANY, pos, size, style)
+            self.canvas = glcanvas.GLCanvas(self, wx.ID_ANY, attribList, pos, size, style)
+
+        self.width = self.height = None
+
         self.context = glcanvas.GLContext(self.canvas)
-        self.sizer.Add(self.canvas, 1, wx.EXPAND)
-        self.SetSizerAndFit(self.sizer)
 
         self.rot_lock = Lock()
         self.basequat = [0, 0, 0, 1]
         self.zoom_factor = 1.0
+        self.angle_z = 0
+        self.angle_x = 0
 
         self.gl_broken = False
 
         # bind events
+        self.canvas.Bind(wx.EVT_SIZE, self.processSizeEvent)
+        if self.canvas is not self:
+            self.Bind(wx.EVT_SIZE, self.OnScrollSize)
+            # do not focus parent (panel like) but its canvas
+            self.SetCanFocus(False)
+
         self.canvas.Bind(wx.EVT_ERASE_BACKGROUND, self.processEraseBackgroundEvent)
-        self.canvas.Bind(wx.EVT_SIZE, self.processSizeEvent)
+        # In wxWidgets 3.0.x there is a clipping bug during resizing
+        # which could be affected by painting the container
+        # self.Bind(wx.EVT_PAINT, self.processPaintEvent)
+        # Upgrade to wxPython 4.1 recommended
         self.canvas.Bind(wx.EVT_PAINT, self.processPaintEvent)
 
+        self.canvas.Bind(wx.EVT_SET_FOCUS, self.processFocus)
+        self.canvas.Bind(wx.EVT_KILL_FOCUS, self.processKillFocus)
+
+    def processFocus(self, ev):
+        # print('processFocus')
+        self.Refresh(False)
+        ev.Skip()
+
+    def processKillFocus(self, ev):
+        # print('processKillFocus')
+        self.Refresh(False)
+        ev.Skip()
+    # def processIdle(self, event):
+    #     print('processIdle')
+    #     event.Skip()
+
+    def Layout(self):
+        return super().Layout()
+
+    def Refresh(self, eraseback=True):
+        # print('Refresh')
+        return super().Refresh(eraseback)
+
+    def OnScrollSize(self, event):
+        self.canvas.SetSize(event.Size)
+
     def processEraseBackgroundEvent(self, event):
         '''Process the erase background event.'''
         pass  # Do nothing, to avoid flashing on MSWin
 
     def processSizeEvent(self, event):
         '''Process the resize event.'''
-        if self.IsFrozen():
-            event.Skip()
-            return
-        if (wx.VERSION > (2, 9) and self.canvas.IsShownOnScreen()) or self.canvas.GetContext():
+
+        # print('processSizeEvent frozen', self.IsFrozen(), event.Size.x, self.ClientSize.x)
+        if not self.IsFrozen() and self.canvas.IsShownOnScreen():
             # Make sure the frame is shown before calling SetCurrent.
             self.canvas.SetCurrent(self.context)
             self.OnReshape()
-            self.Refresh(False)
-            timer = wx.CallLater(100, self.Refresh)
-            timer.Start()
+
+            # self.Refresh(False)
+            # print('Refresh')
         event.Skip()
 
     def processPaintEvent(self, event):
         '''Process the drawing event.'''
+        # print('wxGLPanel.processPaintEvent', self.ClientSize.Width)
         self.canvas.SetCurrent(self.context)
 
         if not self.gl_broken:
             try:
                 self.OnInitGL()
-                self.OnDraw()
+                self.DrawCanvas()
             except pyglet.gl.lib.GLException:
                 self.gl_broken = True
                 logging.error(_("OpenGL failed, disabling it:")
@@ -124,7 +177,7 @@
         # clean up the pyglet OpenGL context
         self.pygletcontext.destroy()
         # call the super method
-        super(wxGLPanel, self).Destroy()
+        super().Destroy()
 
     # ==========================================================================
     # GLFrame OpenGL Event Handlers
@@ -160,6 +213,7 @@
         self.width = max(float(width), 1.0)
         self.height = max(float(height), 1.0)
         self.OnInitGL(call_reshape = False)
+        # print('glViewport', width)
         glViewport(0, 0, width, height)
         glMatrixMode(GL_PROJECTION)
         glLoadIdentity()
@@ -223,13 +277,46 @@
             self.zoomed_height = hratio / minratio
             glScalef(factor * minratio, factor * minratio, 1)
 
-    def OnDraw(self, *args, **kwargs):
+    def DrawCanvas(self):
         """Draw the window."""
+        #import time
+        #start = time.perf_counter()
+        # print('DrawCanvas', self.canvas.GetClientRect())
         self.pygletcontext.set_current()
         glClearColor(*self.color_background)
         glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
         self.draw_objects()
+
+        if self.canvas.HasFocus():
+            self.drawFocus()
         self.canvas.SwapBuffers()
+        #print('Draw took', '%.2f'%(time.perf_counter()-start))
+
+    def drawFocus(self):
+        glColor4f(0, 0, 0, 0.4)
+
+        glPushMatrix()
+        glLoadIdentity()
+
+        glMatrixMode(GL_PROJECTION)
+        glPushMatrix()
+        glLoadIdentity()
+        gluOrtho2D(0, self.width, 0, self.height)
+
+        glLineStipple(1, 0xf0f0)
+        glEnable(GL_LINE_STIPPLE)
+        glBegin(GL_LINE_LOOP)
+        glVertex2f(1, 0)
+        glVertex2f(self.width, 0)
+        glVertex2f(self.width, self.height-1)
+        glVertex2f(1, self.height-1)
+        glEnd()
+        glDisable(GL_LINE_STIPPLE)
+
+        glPopMatrix() # restore PROJECTION
+
+        glMatrixMode(GL_MODELVIEW)
+        glPopMatrix()
 
     # ==========================================================================
     # To be implemented by a sub class
@@ -317,35 +404,54 @@
         self.zoom_factor *= factor
         if to:
             glTranslatef(-delta_x, -delta_y, 0)
-        wx.CallAfter(self.Refresh)
+        # For wxPython (<4.1) and GTK:
+        # when you resize (enlarge) 3d view fast towards the log pane
+        # sash garbage may remain in GLCanvas
+        # The following refresh clears it at the cost of
+        # doubled frame draws.
+        # wx.CallAfter(self.Refresh)
+        self.Refresh(False)
 
     def zoom_to_center(self, factor):
         self.canvas.SetCurrent(self.context)
         x, y, _ = self.mouse_to_3d(self.width / 2, self.height / 2)
         self.zoom(factor, (x, y))
 
+    def orbit(self, p1x, p1y, p2x, p2y):
+        rz = p2x-p1x
+        self.angle_z-=rz
+        rotz = axis_to_quat([0.0,0.0,1.0],self.angle_z)
+
+        rx = p2y-p1y
+        self.angle_x+=rx
+        rota = axis_to_quat([1.0,0.0,0.0],self.angle_x)
+        return mulquat(rotz,rota)
+
     def handle_rotation(self, event):
         if self.initpos is None:
-            self.initpos = event.GetPositionTuple()
+            self.initpos = event.GetPosition()
         else:
             p1 = self.initpos
-            p2 = event.GetPositionTuple()
+            p2 = event.GetPosition()
             sz = self.GetClientSize()
-            p1x = float(p1[0]) / (sz[0] / 2) - 1
-            p1y = 1 - float(p1[1]) / (sz[1] / 2)
-            p2x = float(p2[0]) / (sz[0] / 2) - 1
-            p2y = 1 - float(p2[1]) / (sz[1] / 2)
+            p1x = p1[0] / (sz[0] / 2) - 1
+            p1y = 1 - p1[1] / (sz[1] / 2)
+            p2x = p2[0] / (sz[0] / 2) - 1
+            p2y = 1 - p2[1] / (sz[1] / 2)
             quat = trackball(p1x, p1y, p2x, p2y, self.dist / 250.0)
             with self.rot_lock:
-                self.basequat = mulquat(self.basequat, quat)
+                if self.orbit_control:
+                    self.basequat = self.orbit(p1x, p1y, p2x, p2y)
+                else:
+                    self.basequat = mulquat(self.basequat, quat)
             self.initpos = p2
 
     def handle_translation(self, event):
         if self.initpos is None:
-            self.initpos = event.GetPositionTuple()
+            self.initpos = event.GetPosition()
         else:
             p1 = self.initpos
-            p2 = event.GetPositionTuple()
+            p2 = event.GetPosition()
             if self.orthographic:
                 x1, y1, _ = self.mouse_to_3d(p1[0], p1[1])
                 x2, y2, _ = self.mouse_to_3d(p2[0], p2[1])
--- a/printrun-src/printrun/gl/trackball.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gl/trackball.py	Wed Jan 20 10:15:13 2021 +0100
@@ -1,5 +1,3 @@
-#!/usr/bin/env python
-
 # This file is part of the Printrun suite.
 #
 # Printrun is free software: you can redistribute it and/or modify
@@ -35,7 +33,7 @@
     a = cross(p2, p1)
 
     d = map(lambda x, y: x - y, p1, p2)
-    t = math.sqrt(sum(map(lambda x: x * x, d))) / (2.0 * TRACKBALLSIZE)
+    t = math.sqrt(sum(x * x for x in d)) / (2.0 * TRACKBALLSIZE)
 
     if t > 1.0:
         t = 1.0
@@ -46,9 +44,9 @@
     return axis_to_quat(a, phi)
 
 def axis_to_quat(a, phi):
-    lena = math.sqrt(sum(map(lambda x: x * x, a)))
-    q = map(lambda x: x * (1 / lena), a)
-    q = map(lambda x: x * math.sin(phi / 2.0), q)
+    lena = math.sqrt(sum(x * x for x in a))
+    q = [x * (1 / lena) for x in a]
+    q = [x * math.sin(phi / 2.0) for x in q]
     q.append(math.cos(phi / 2.0))
     return q
 
--- a/printrun-src/printrun/gui/__init__.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gui/__init__.py	Wed Jan 20 10:15:13 2021 +0100
@@ -17,8 +17,10 @@
 
 try:
     import wx
+    if wx.VERSION < (4,):
+        raise ImportError()
 except:
-    logging.error(_("WX is not installed. This program requires WX to run."))
+    logging.error(_("WX >= 4 is not installed. This program requires WX >= 4 to run."))
     raise
 
 from printrun.utils import install_locale
@@ -33,13 +35,14 @@
 
     def __init__(self, root, label, parentpanel, parentsizers):
         super(ToggleablePane, self).__init__(wx.HORIZONTAL)
-        if not parentpanel: parentpanel = root.panel
+        if not parentpanel:
+            parentpanel = root.panel
         self.root = root
         self.visible = True
         self.parentpanel = parentpanel
         self.parentsizers = parentsizers
         self.panepanel = root.newPanel(parentpanel)
-        self.button = wx.Button(parentpanel, -1, label, size = (22, 18), style = wx.BU_EXACTFIT)
+        self.button = wx.Button(parentpanel, -1, label, size = (35, 18), style = wx.BU_EXACTFIT)
         self.button.Bind(wx.EVT_BUTTON, self.toggle)
 
     def toggle(self, event):
@@ -54,7 +57,7 @@
 
 class LeftPaneToggleable(ToggleablePane):
     def __init__(self, root, parentpanel, parentsizers):
-        super(LeftPaneToggleable, self).__init__(root, "<", parentpanel, parentsizers)
+        super().__init__(root, "<", parentpanel, parentsizers)
         self.Add(self.panepanel, 0, wx.EXPAND)
         self.Add(self.button, 0)
 
@@ -89,8 +92,9 @@
         self.splitter.SetSashPosition(self.splitter.GetSize()[0] - self.orig_width)
         self.splitter.SetMinimumPaneSize(self.orig_min_size)
         self.splitter.SetSashGravity(self.orig_gravity)
-        if hasattr(self.splitter, "SetSashSize"): self.splitter.SetSashSize(self.orig_sash_size)
-        if hasattr(self.splitter, "SetSashInvisible"): self.splitter.SetSashInvisible(False)
+        if getattr(self.splitter, 'SetSashSize', False):
+            self.splitter.SetSashSize(self.orig_sash_size)
+        getattr(self.splitter, 'SetSashInvisible', bool)(False)
         for sizer in self.parentsizers:
             sizer.Layout()
 
@@ -103,20 +107,20 @@
         self.splitter.SetMinimumPaneSize(button_width)
         self.splitter.SetSashGravity(1)
         self.splitter.SetSashPosition(self.splitter.GetSize()[0] - button_width)
-        if hasattr(self.splitter, "SetSashSize"):
+        if getattr(self.splitter, 'SetSashSize', False):
             self.orig_sash_size = self.splitter.GetSashSize()
             self.splitter.SetSashSize(0)
-        if hasattr(self.splitter, "SetSashInvisible"): self.splitter.SetSashInvisible(True)
+        getattr(self.splitter, 'SetSashInvisible', bool)(True)
         for sizer in self.parentsizers:
             sizer.Layout()
 
 class MainWindow(wx.Frame):
 
     def __init__(self, *args, **kwargs):
-        super(MainWindow, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
         # this list will contain all controls that should be only enabled
         # when we're connected to a printer
-        self.panel = wx.Panel(self, -1)
+        self.panel = wx.Panel(self)
         self.reset_ui()
         self.statefulControls = []
 
@@ -131,7 +135,8 @@
 
     def registerPanel(self, panel, add_to_list = True):
         panel.SetBackgroundColour(self.bgcolor)
-        if add_to_list: self.panels.append(panel)
+        if add_to_list:
+            self.panels.append(panel)
 
     def createTabbedGui(self):
         self.notesizer = wx.BoxSizer(wx.VERTICAL)
@@ -199,12 +204,9 @@
             self.notebook.AddPage(page4panel, _("G-Code Plater"))
         self.panel.SetSizer(self.notesizer)
         self.panel.Bind(wx.EVT_MOUSE_EVENTS, self.editbutton)
-        self.Bind(wx.EVT_CLOSE, self.kill)
 
         # Custom buttons
-        if wx.VERSION > (2, 9): self.cbuttonssizer = wx.WrapSizer(wx.HORIZONTAL)
-        else: self.cbuttonssizer = wx.GridBagSizer()
-        self.cbuttonssizer = wx.GridBagSizer()
+        self.cbuttonssizer = wx.WrapSizer(wx.HORIZONTAL)
         self.centerpanel = self.newPanel(page1panel2)
         self.centerpanel.SetSizer(self.cbuttonssizer)
         rightsizer.Add(self.centerpanel, 0, wx.ALIGN_CENTER)
@@ -237,11 +239,15 @@
         left_sizer.Add(controls_panel, 1, wx.EXPAND)
         left_pane.set_sizer(left_sizer)
         self.lowersizer.Add(leftpanel, 0, wx.EXPAND)
-        if not compact:  # Use a splitterwindow to group viz and log
+        if compact:
+            vizpanel = self.newPanel(lowerpanel)
+            logpanel = self.newPanel(left_real_panel)
+        else:
+            # Use a splitterwindow to group viz and log
             rightpanel = self.newPanel(lowerpanel)
             rightsizer = wx.BoxSizer(wx.VERTICAL)
             rightpanel.SetSizer(rightsizer)
-            self.splitterwindow = wx.SplitterWindow(rightpanel, style = wx.SP_3D)
+            self.splitterwindow = wx.SplitterWindow(rightpanel, style = wx.SP_3D | wx.SP_LIVE_UPDATE)
             self.splitterwindow.SetMinimumPaneSize(150)
             self.splitterwindow.SetSashGravity(0.8)
             rightsizer.Add(self.splitterwindow, 1, wx.EXPAND)
@@ -250,13 +256,9 @@
             self.splitterwindow.SplitVertically(vizpanel, logpanel,
                                                 self.settings.last_sash_position)
             self.splitterwindow.shrinked = False
-        else:
-            vizpanel = self.newPanel(lowerpanel)
-            logpanel = self.newPanel(left_real_panel)
         viz_pane = VizPane(self, vizpanel)
         # Custom buttons
-        if wx.VERSION > (2, 9): self.cbuttonssizer = wx.WrapSizer(wx.HORIZONTAL)
-        else: self.cbuttonssizer = wx.GridBagSizer()
+        self.cbuttonssizer = wx.WrapSizer(wx.HORIZONTAL)
         self.centerpanel = self.newPanel(vizpanel)
         self.centerpanel.SetSizer(self.cbuttonssizer)
         viz_pane.Add(self.centerpanel, 0, flag = wx.ALIGN_CENTER)
@@ -267,16 +269,15 @@
             log_pane = LogPaneToggleable(self, logpanel, [self.lowersizer])
             left_pane.parentsizers.append(self.splitterwindow)
         logpanel.SetSizer(log_pane)
-        if not compact:
-            self.lowersizer.Add(rightpanel, 1, wx.EXPAND)
-        else:
+        if compact:
             left_sizer.Add(logpanel, 1, wx.EXPAND)
             self.lowersizer.Add(vizpanel, 1, wx.EXPAND)
+        else:
+            self.lowersizer.Add(rightpanel, 1, wx.EXPAND)
         self.mainsizer.Add(upperpanel, 0, wx.EXPAND)
         self.mainsizer.Add(lowerpanel, 1, wx.EXPAND)
         self.panel.SetSizer(self.mainsizer)
         self.panel.Bind(wx.EVT_MOUSE_EVENTS, self.editbutton)
-        self.Bind(wx.EVT_CLOSE, self.kill)
 
         self.mainsizer.Layout()
         # This prevents resizing below a reasonnable value
--- a/printrun-src/printrun/gui/bufferedcanvas.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gui/bufferedcanvas.py	Wed Jan 20 10:15:13 2021 +0100
@@ -90,7 +90,7 @@
         self.Refresh()
 
     def getWidthHeight(self):
-        width, height = self.GetClientSizeTuple()
+        width, height = self.GetClientSize()
         if width == 0:
             width = 1
         if height == 0:
@@ -103,7 +103,7 @@
 
     def onPaint(self, event):
         # Blit the front buffer to the screen
-        w, h = self.GetClientSizeTuple()
+        w, h = self.GetClientSize()
         if not w or not h:
             return
         else:
--- a/printrun-src/printrun/gui/controls.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gui/controls.py	Wed Jan 20 10:15:13 2021 +0100
@@ -1,6 +1,3 @@
-# FILE MODIFIED BY NEOSOFT - MALTE DI DONATO
-# Embed Lasercut controls
-
 # This file is part of the Printrun suite.
 #
 # Printrun is free software: you can redistribute it and/or modify
@@ -26,18 +23,16 @@
 
 from .utils import make_button, make_custom_button
 
-from .widgets import PronterOptions
-
 class XYZControlsSizer(wx.GridBagSizer):
 
     def __init__(self, root, parentpanel = None):
         super(XYZControlsSizer, self).__init__()
         if not parentpanel: parentpanel = root.panel
         root.xyb = XYButtons(parentpanel, root.moveXY, root.homeButtonClicked, root.spacebarAction, root.bgcolor, zcallback=root.moveZ)
+        root.xyb.SetToolTip(_('[J]og controls. (Shift)+TAB ESC Shift/Ctrl+(arrows PgUp/PgDn)'))
         self.Add(root.xyb, pos = (0, 1), flag = wx.ALIGN_CENTER)
         root.zb = ZButtons(parentpanel, root.moveZ, root.bgcolor)
         self.Add(root.zb, pos = (0, 2), flag = wx.ALIGN_CENTER)
-        wx.CallAfter(root.xyb.SetFocus)
 
 def add_extra_controls(self, root, parentpanel, extra_buttons = None, mini_mode = False):
     standalone_mode = extra_buttons is not None
@@ -55,8 +50,6 @@
     else:
         e_base_line = base_line + 2
 
-    lasercut_base_line = 11
-
     pos_mapping = {
         "htemp_label": (base_line + 0, 0),
         "htemp_off": (base_line + 0, 2),
@@ -75,14 +68,6 @@
         "tempdisp": (tempdisp_line, 0),
         "extrude": (3, 0),
         "reverse": (3, 2),
-        "lasercut_optionsbtn": (lasercut_base_line, 0),
-        "lasercut_printbtn": (lasercut_base_line, 2),
-        "lasercut_material_thickness": (lasercut_base_line+1, 4),
-        "lasercut_material_thickness_label": (lasercut_base_line+1, 0),
-        "lasercut_pass_count": (lasercut_base_line+2, 4),
-        "lasercut_pass_count_label": (lasercut_base_line+2, 0),
-        "lasercut_pass_zdiff": (lasercut_base_line+3, 4),
-        "lasercut_pass_zdiff_label": (lasercut_base_line+3, 0),
     }
 
     span_mapping = {
@@ -106,8 +91,8 @@
     }
 
     if standalone_mode:
-        pos_mapping["tempgraph"] = (base_line + 5, 0)
-        span_mapping["tempgraph"] = (5, 6)
+        pos_mapping["tempgraph"] = (base_line + 6, 0)
+        span_mapping["tempgraph"] = (3, 2)
     elif mini_mode:
         pos_mapping["tempgraph"] = (base_line + 2, 0)
         span_mapping["tempgraph"] = (1, 5)
@@ -140,46 +125,17 @@
             container = self
         container.Add(widget, *args, **kwargs)
 
-    # Lasercutter quick controls #
-
-    root.lc_optionsbtn = make_button(parentpanel, _("Lasercutter options"), lambda e: PronterOptions(root, "Laser"), _("Open Lasercutter options"), style = wx.BU_EXACTFIT)
-    root.printerControls.append(root.lc_optionsbtn)
-    add("lasercut_optionsbtn", root.lc_optionsbtn, flag = wx.EXPAND)
-
-    root.lc_printbtn = make_button(parentpanel, _("Start cutting"), root.on_lc_printfile, _("Start printjob with lasercutting features"), style = wx.BU_EXACTFIT)
-    root.printerControls.append(root.lc_printbtn)
-    add("lasercut_printbtn", root.lc_printbtn, flag = wx.EXPAND)
-
-
-    root.lc_pass_count = speed_spin = FloatSpin(parentpanel, -1, value = root.settings.lc_pass_count, min_val = 1, max_val = 10, digits = 0, style = wx.ALIGN_LEFT, size = (80, -1))
-    add("lasercut_pass_count", root.lc_pass_count)
-    add("lasercut_pass_count_label", wx.StaticText(parentpanel, -1, _("Number of cutting passes:")), flag = wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_LEFT)
-
-    root.lc_pass_zdiff = speed_spin = FloatSpin(parentpanel, -1, increment = 0.1, value = root.settings.lc_pass_zdiff, min_val = -2, max_val = 2, digits = 1, style = wx.ALIGN_LEFT, size = (80, -1))
-    add("lasercut_pass_zdiff", root.lc_pass_zdiff)
-    add("lasercut_pass_zdiff_label", wx.StaticText(parentpanel, -1, _("Z movement after each cut:")), flag = wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_LEFT)
-
-    root.lc_material_thickness = speed_spin = FloatSpin(parentpanel, -1, increment = 0.1, value = root.settings.lc_material_thickness, min_val = 0, max_val = 75, digits = 1, style = wx.ALIGN_LEFT, size = (80, -1))
-    add("lasercut_material_thickness", root.lc_material_thickness)
-    add("lasercut_material_thickness_label", wx.StaticText(parentpanel, -1, _("Material Thickness:")), flag = wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_LEFT)
-
-
-
     # Hotend & bed temperatures #
 
     # Hotend temp
     add("htemp_label", wx.StaticText(parentpanel, -1, _("Heat:")), flag = wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_RIGHT)
-    htemp_choices = [root.temps[i] + " (" + i + ")" for i in sorted(root.temps.keys(), key = lambda x:root.temps[x])]
 
     root.settoff = make_button(parentpanel, _("Off"), lambda e: root.do_settemp("off"), _("Switch Hotend Off"), size = (38, -1), style = wx.BU_EXACTFIT)
     root.printerControls.append(root.settoff)
     add("htemp_off", root.settoff)
 
-    if root.settings.last_temperature not in map(float, root.temps.values()):
-        htemp_choices = [str(root.settings.last_temperature)] + htemp_choices
-    root.htemp = wx.ComboBox(parentpanel, -1, choices = htemp_choices,
-                             style = wx.CB_DROPDOWN, size = (80, -1))
-    root.htemp.SetToolTip(wx.ToolTip(_("Select Temperature for Hotend")))
+    root.htemp = wx.ComboBox(parentpanel, style = wx.CB_DROPDOWN, size = (115, -1))
+    root.htemp.SetToolTip(wx.ToolTip(_("Select Temperature for [H]otend")))
     root.htemp.Bind(wx.EVT_COMBOBOX, root.htemp_change)
 
     add("htemp_val", root.htemp)
@@ -189,17 +145,13 @@
 
     # Bed temp
     add("btemp_label", wx.StaticText(parentpanel, -1, _("Bed:")), flag = wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_RIGHT)
-    btemp_choices = [root.bedtemps[i] + " (" + i + ")" for i in sorted(root.bedtemps.keys(), key = lambda x:root.temps[x])]
 
     root.setboff = make_button(parentpanel, _("Off"), lambda e: root.do_bedtemp("off"), _("Switch Heated Bed Off"), size = (38, -1), style = wx.BU_EXACTFIT)
     root.printerControls.append(root.setboff)
     add("btemp_off", root.setboff)
 
-    if root.settings.last_bed_temperature not in map(float, root.bedtemps.values()):
-        btemp_choices = [str(root.settings.last_bed_temperature)] + btemp_choices
-    root.btemp = wx.ComboBox(parentpanel, -1, choices = btemp_choices,
-                             style = wx.CB_DROPDOWN, size = (80, -1))
-    root.btemp.SetToolTip(wx.ToolTip(_("Select Temperature for Heated Bed")))
+    root.btemp = wx.ComboBox(parentpanel, style = wx.CB_DROPDOWN, size = (115, -1))
+    root.btemp.SetToolTip(wx.ToolTip(_("Select Temperature for Heated [B]ed")))
     root.btemp.Bind(wx.EVT_COMBOBOX, root.btemp_change)
     add("btemp_val", root.btemp)
 
@@ -207,37 +159,31 @@
     root.printerControls.append(root.setbbtn)
     add("btemp_set", root.setbbtn, flag = wx.EXPAND)
 
-    root.btemp.SetValue(str(root.settings.last_bed_temperature))
-    root.htemp.SetValue(str(root.settings.last_temperature))
-
-    # added for an error where only the bed would get (pla) or (abs).
-    # This ensures, if last temp is a default pla or abs, it will be marked so.
-    # if it is not, then a (user) remark is added. This denotes a manual entry
+    def set_labeled(temp, choices, widget):
+        choices = [(float(p[1]), p[0]) for p in choices.items()]
+        if not next((1 for p in choices if p[0] == temp), False):
+            choices.append((temp, 'user'))
 
-    for i in btemp_choices:
-        if i.split()[0] == str(root.settings.last_bed_temperature).split('.')[0] or i.split()[0] == str(root.settings.last_bed_temperature):
-            root.btemp.SetValue(i)
-    for i in htemp_choices:
-        if i.split()[0] == str(root.settings.last_temperature).split('.')[0] or i.split()[0] == str(root.settings.last_temperature):
-            root.htemp.SetValue(i)
+        choices = sorted(choices)
+        widget.Items = ['%s (%s)'%tl for tl in choices]
+        widget.Selection = next((i for i, tl in enumerate(choices) if tl[0] == temp), -1)
 
-    if '(' not in root.btemp.Value:
-        root.btemp.SetValue(root.btemp.Value + ' (user)')
-    if '(' not in root.htemp.Value:
-        root.htemp.SetValue(root.htemp.Value + ' (user)')
+    set_labeled(root.settings.last_bed_temperature, root.bedtemps, root.btemp)
+    set_labeled(root.settings.last_temperature, root.temps, root.htemp)
 
     # Speed control #
     speedpanel = root.newPanel(parentpanel)
     speedsizer = wx.BoxSizer(wx.HORIZONTAL)
-    speedsizer.Add(wx.StaticText(speedpanel, -1, _("Print speed:")), flag = wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_RIGHT)
+    speedsizer.Add(wx.StaticText(speedpanel, -1, _("Print speed:")), flag = wx.ALIGN_CENTER_VERTICAL)
 
     root.speed_slider = wx.Slider(speedpanel, -1, 100, 1, 300)
     speedsizer.Add(root.speed_slider, 1, flag = wx.EXPAND)
 
-    root.speed_spin = FloatSpin(speedpanel, -1, value = 100, min_val = 1, max_val = 300, digits = 0, style = wx.ALIGN_LEFT, size = (80, -1))
+    root.speed_spin = wx.SpinCtrlDouble(speedpanel, -1, initial = 100, min = 1, max = 300, style = wx.ALIGN_LEFT, size = (115, -1))
+    root.speed_spin.SetDigits(0)
     speedsizer.Add(root.speed_spin, 0, flag = wx.ALIGN_CENTER_VERTICAL)
     root.speed_label = wx.StaticText(speedpanel, -1, _("%"))
-    speedsizer.Add(root.speed_label, flag = wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_RIGHT)
+    speedsizer.Add(root.speed_label, flag = wx.ALIGN_CENTER_VERTICAL)
 
     def speedslider_set(event):
         root.do_setspeed()
@@ -252,7 +198,7 @@
         value = root.speed_spin.GetValue()
         root.speed_setbtn.SetBackgroundColour("red")
         root.speed_slider.SetValue(value)
-    root.speed_spin.Bind(wx.EVT_SPINCTRL, speedslider_spin)
+    root.speed_spin.Bind(wx.EVT_SPINCTRLDOUBLE, speedslider_spin)
 
     def speedslider_scroll(event):
         value = root.speed_slider.GetValue()
@@ -263,15 +209,15 @@
     # Flow control #
     flowpanel = root.newPanel(parentpanel)
     flowsizer = wx.BoxSizer(wx.HORIZONTAL)
-    flowsizer.Add(wx.StaticText(flowpanel, -1, _("Print flow:")), flag = wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_RIGHT)
+    flowsizer.Add(wx.StaticText(flowpanel, -1, _("Print flow:")), flag = wx.ALIGN_CENTER_VERTICAL)
 
     root.flow_slider = wx.Slider(flowpanel, -1, 100, 1, 300)
     flowsizer.Add(root.flow_slider, 1, flag = wx.EXPAND)
 
-    root.flow_spin = FloatSpin(flowpanel, -1, value = 100, min_val = 1, max_val = 300, digits = 0, style = wx.ALIGN_LEFT, size = (60, -1))
+    root.flow_spin = wx.SpinCtrlDouble(flowpanel, -1, initial = 100, min = 1, max = 300, style = wx.ALIGN_LEFT, size = (115, -1))
     flowsizer.Add(root.flow_spin, 0, flag = wx.ALIGN_CENTER_VERTICAL)
     root.flow_label = wx.StaticText(flowpanel, -1, _("%"))
-    flowsizer.Add(root.flow_label, flag = wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_RIGHT)
+    flowsizer.Add(root.flow_label, flag = wx.ALIGN_CENTER_VERTICAL)
 
     def flowslider_set(event):
         root.do_setflow()
@@ -286,7 +232,7 @@
         value = root.flow_spin.GetValue()
         root.flow_setbtn.SetBackgroundColour("red")
         root.flow_slider.SetValue(value)
-    root.flow_spin.Bind(wx.EVT_SPINCTRL, flowslider_spin)
+    root.flow_spin.Bind(wx.EVT_SPINCTRLDOUBLE, flowslider_spin)
 
     def flowslider_scroll(event):
         value = root.flow_slider.GetValue()
@@ -298,25 +244,32 @@
 
     if root.display_gauges:
         root.hottgauge = TempGauge(parentpanel, size = (-1, 24), title = _("Heater:"), maxval = 300, bgcolor = root.bgcolor)
+        root.hottgauge.SetTarget(root.settings.last_temperature)
+        # root.hsetpoint = root.settings.last_temperature
         add("htemp_gauge", root.hottgauge, flag = wx.EXPAND)
         root.bedtgauge = TempGauge(parentpanel, size = (-1, 24), title = _("Bed:"), maxval = 150, bgcolor = root.bgcolor)
+        root.bedtgauge.SetTarget(root.settings.last_bed_temperature)
+        # root.bsetpoint = root.settings.last_bed_temperature
         add("btemp_gauge", root.bedtgauge, flag = wx.EXPAND)
 
-        def hotendgauge_scroll_setpoint(e):
-            rot = e.GetWheelRotation()
-            if rot > 0:
-                root.do_settemp(str(root.hsetpoint + 1))
-            elif rot < 0:
-                root.do_settemp(str(max(0, root.hsetpoint - 1)))
+        def scroll_gauge(rot, cmd, setpoint):
+            if rot:
+                temp = setpoint + (1 if rot > 0 else -1)
+                cmd(str(max(0, temp)))
+
+        def hotend_handler(e):
+            scroll_gauge(e.WheelRotation, root.do_settemp, root.hsetpoint)
 
-        def bedgauge_scroll_setpoint(e):
-            rot = e.GetWheelRotation()
-            if rot > 0:
-                root.do_settemp(str(root.bsetpoint + 1))
-            elif rot < 0:
-                root.do_settemp(str(max(0, root.bsetpoint - 1)))
-        root.hottgauge.Bind(wx.EVT_MOUSEWHEEL, hotendgauge_scroll_setpoint)
-        root.bedtgauge.Bind(wx.EVT_MOUSEWHEEL, bedgauge_scroll_setpoint)
+        def bed_handler(e):
+            scroll_gauge(e.WheelRotation, root.do_bedtemp, root.bsetpoint)
+        root.hottgauge.Bind(wx.EVT_MOUSEWHEEL, hotend_handler)
+        root.bedtgauge.Bind(wx.EVT_MOUSEWHEEL, bed_handler)
+
+        def updateGauge(e, gauge):
+            gauge.SetTarget(float(e.String.split()[0]))
+
+        root.htemp.Bind(wx.EVT_TEXT, lambda e: updateGauge(e, root.hottgauge))
+        root.btemp.Bind(wx.EVT_TEXT, lambda e: updateGauge(e, root.bedtgauge))
 
     # Temperature (M105) feedback display #
     root.tempdisp = wx.StaticText(parentpanel, -1, "", style = wx.ST_NO_AUTORESIZE)
@@ -345,10 +298,11 @@
     esettingspanel = root.newPanel(parentpanel)
     esettingssizer = wx.GridBagSizer()
     esettingssizer.SetEmptyCellSize((0, 0))
-    root.edist = FloatSpin(esettingspanel, -1, value = root.settings.last_extrusion, min_val = 0, max_val = 1000, size = (90, -1), digits = 1)
+    root.edist = wx.SpinCtrlDouble(esettingspanel, -1, initial = root.settings.last_extrusion, min = 0, max = 1000, size = (135, -1))
+    root.edist.SetDigits(1)
+    root.edist.Bind(wx.EVT_SPINCTRLDOUBLE, root.setfeeds)
     root.edist.SetBackgroundColour((225, 200, 200))
     root.edist.SetForegroundColour("black")
-    root.edist.Bind(wx.EVT_SPINCTRL, root.setfeeds)
     root.edist.Bind(wx.EVT_TEXT, root.setfeeds)
     add("edist_label", wx.StaticText(esettingspanel, -1, _("Length:")), container = esettingssizer, flag = wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_LEFT | wx.RIGHT | wx.LEFT, border = 5)
     add("edist_val", root.edist, container = esettingssizer, flag = wx.ALIGN_CENTER | wx.RIGHT, border = 5)
@@ -356,11 +310,12 @@
     add("edist_unit", wx.StaticText(esettingspanel, -1, unit_label), container = esettingssizer, flag = wx.ALIGN_CENTER | wx.RIGHT, border = 5)
     root.edist.SetToolTip(wx.ToolTip(_("Amount to Extrude or Retract (mm)")))
     if not mini_mode:
-        root.efeedc = FloatSpin(esettingspanel, -1, value = root.settings.e_feedrate, min_val = 0, max_val = 50000, size = (90, -1), digits = 1)
+        root.efeedc = wx.SpinCtrlDouble(esettingspanel, -1, initial = root.settings.e_feedrate, min = 0, max = 50000, size = (145, -1))
+        root.efeedc.SetDigits(1)
+        root.efeedc.Bind(wx.EVT_SPINCTRLDOUBLE, root.setfeeds)
         root.efeedc.SetToolTip(wx.ToolTip(_("Extrude / Retract speed (mm/min)")))
         root.efeedc.SetBackgroundColour((225, 200, 200))
         root.efeedc.SetForegroundColour("black")
-        root.efeedc.Bind(wx.EVT_SPINCTRL, root.setfeeds)
         root.efeedc.Bind(wx.EVT_TEXT, root.setfeeds)
         add("efeed_val", root.efeedc, container = esettingssizer, flag = wx.ALIGN_CENTER | wx.RIGHT, border = 5)
         add("efeed_label", wx.StaticText(esettingspanel, -1, _("Speed:")), container = esettingssizer, flag = wx.ALIGN_LEFT)
@@ -455,12 +410,12 @@
             else:
                 self.extra_buttons[key] = btn
 
-        root.xyfeedc = wx.SpinCtrl(lltspanel, -1, str(root.settings.xy_feedrate), min = 0, max = 50000, size = (97, -1))
+        root.xyfeedc = wx.SpinCtrl(lltspanel, -1, str(root.settings.xy_feedrate), min = 0, max = 50000, size = (130, -1))
         root.xyfeedc.SetToolTip(wx.ToolTip(_("Set Maximum Speed for X & Y axes (mm/min)")))
-        llts.Add(wx.StaticText(lltspanel, -1, _("XY:")), flag = wx.ALIGN_RIGHT | wx.ALIGN_CENTER_VERTICAL)
+        llts.Add(wx.StaticText(lltspanel, -1, _("XY:")), flag = wx.ALIGN_CENTER_VERTICAL)
         llts.Add(root.xyfeedc)
-        llts.Add(wx.StaticText(lltspanel, -1, _("mm/min Z:")), flag = wx.ALIGN_RIGHT | wx.ALIGN_CENTER_VERTICAL)
-        root.zfeedc = wx.SpinCtrl(lltspanel, -1, str(root.settings.z_feedrate), min = 0, max = 50000, size = (90, -1))
+        llts.Add(wx.StaticText(lltspanel, -1, _("mm/min Z:")), flag = wx.ALIGN_CENTER_VERTICAL)
+        root.zfeedc = wx.SpinCtrl(lltspanel, -1, str(root.settings.z_feedrate), min = 0, max = 50000, size = (130, -1))
         root.zfeedc.SetToolTip(wx.ToolTip(_("Set Maximum Speed for Z axis (mm/min)")))
         llts.Add(root.zfeedc,)
 
--- a/printrun-src/printrun/gui/graph.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gui/graph.py	Wed Jan 20 10:15:13 2021 +0100
@@ -1,5 +1,3 @@
-#!/usr/bin/env python
-
 # This file is part of the Printrun suite.
 #
 # Printrun is free software: you can redistribute it and/or modify
@@ -17,6 +15,7 @@
 
 import wx
 from math import log10, floor, ceil
+from bisect import bisect_left
 
 from printrun.utils import install_locale
 install_locale('pronterface')
@@ -25,14 +24,26 @@
 
 class GraphWindow(wx.Frame):
     def __init__(self, root, parent_graph = None, size = (600, 600)):
-        super(GraphWindow, self).__init__(None, title = _("Temperature graph"),
+        super().__init__(None, title = _("Temperature graph"),
                                           size = size)
-        panel = wx.Panel(self, -1)
+        self.parentg = parent_graph
+        panel = wx.Panel(self)
         vbox = wx.BoxSizer(wx.VERTICAL)
         self.graph = Graph(panel, wx.ID_ANY, root, parent_graph = parent_graph)
         vbox.Add(self.graph, 1, wx.EXPAND)
         panel.SetSizer(vbox)
 
+    def Destroy(self):
+        self.graph.StopPlotting()
+        if self.parentg is not None:
+            self.parentg.window=None
+        return super().Destroy()
+
+    def __del__(self):
+        if self.parentg is not None:
+            self.parentg.window=None
+        self.graph.StopPlotting()
+
 class Graph(BufferedCanvas):
     '''A class to show a Graph with Pronterface.'''
 
@@ -40,7 +51,7 @@
                  size = wx.Size(150, 80), style = 0, parent_graph = None):
         # Forcing a no full repaint to stop flickering
         style = style | wx.NO_FULL_REPAINT_ON_RESIZE
-        super(Graph, self).__init__(parent, id, pos, size, style)
+        super().__init__(parent, id, pos, size, style)
         self.root = root
 
         if parent_graph is not None:
@@ -50,7 +61,7 @@
             self.extruder1targettemps = parent_graph.extruder1targettemps
             self.bedtemps = parent_graph.bedtemps
             self.bedtargettemps = parent_graph.bedtargettemps
-	    self.fanpowers=parent_graph.fanpowers
+            self.fanpowers=parent_graph.fanpowers
         else:
             self.extruder0temps = [0]
             self.extruder0targettemps = [0]
@@ -58,10 +69,11 @@
             self.extruder1targettemps = [0]
             self.bedtemps = [0]
             self.bedtargettemps = [0]
-	    self.fanpowers= [0]
+            self.fanpowers= [0]
 
         self.timer = wx.Timer(self)
         self.Bind(wx.EVT_TIMER, self.updateTemperatures, self.timer)
+        self.Bind(wx.EVT_WINDOW_DESTROY, self.processDestroy)
 
         self.minyvalue = 0
         self.maxyvalue = 260
@@ -76,9 +88,16 @@
         self.xsteps = 60  # Covering 1 minute in the graph
 
         self.window = None
+        self.reserved = []
+
+    def processDestroy(self, event):
+        # print('processDestroy')
+        self.StopPlotting()
+        self.Unbind(wx.EVT_TIMER)
+        event.Skip()
 
     def show_graph_window(self, event = None):
-        if not self.window:
+        if self.window is None or not self.window:
             self.window = GraphWindow(self.root, self)
             self.window.Show()
             if self.timer.IsRunning():
@@ -90,13 +109,14 @@
         if self.window: self.window.Close()
 
     def updateTemperatures(self, event):
+        # print('updateTemperatures')
         self.AddBedTemperature(self.bedtemps[-1])
         self.AddBedTargetTemperature(self.bedtargettemps[-1])
         self.AddExtruder0Temperature(self.extruder0temps[-1])
         self.AddExtruder0TargetTemperature(self.extruder0targettemps[-1])
         self.AddExtruder1Temperature(self.extruder1temps[-1])
         self.AddExtruder1TargetTemperature(self.extruder1targettemps[-1])
-	self.AddFanPower(self.fanpowers[-1])
+        self.AddFanPower(self.fanpowers[-1])
         if self.rescaley:
             self._ybounds.update()
         self.Refresh()
@@ -124,11 +144,10 @@
 
         # draw vertical bars
         dc.SetPen(wx.Pen(wx.Colour(225, 225, 225), 1))
+        xscale = float(self.width - 1) / (self.xbars - 1)
         for x in range(self.xbars + 1):
-            dc.DrawLine(x * (float(self.width - 1) / (self.xbars - 1)),
-                        0,
-                        x * (float(self.width - 1) / (self.xbars - 1)),
-                        self.height)
+            x = x * xscale
+            dc.DrawLine(x, 0, x, self.height)
 
         # draw horizontal bars
         spacing = self._calculate_spacing()  # spacing between bars, in degrees
@@ -141,15 +160,17 @@
             degrees = y * spacing
             y_pos = self._y_pos(degrees)
             dc.DrawLine(0, y_pos, self.width, y_pos)
-            gc.DrawText(unicode(y * spacing),
-                        1, y_pos - (font.GetPointSize() / 2))
+            label = str(y * spacing)
+            label_y = y_pos - font.GetPointSize() / 2
+            self.layoutText(label, 1, label_y, gc)
+            gc.DrawText(label, 1, label_y)
 
-        if self.timer.IsRunning() is False:
+        if not self.timer.IsRunning():
             font = wx.Font(14, wx.DEFAULT, wx.NORMAL, wx.BOLD)
             gc.SetFont(font, wx.Colour(3, 4, 4))
             gc.DrawText("Graph offline",
-                        self.width / 2 - (font.GetPointSize() * 3),
-                        self.height / 2 - (font.GetPointSize() * 1))
+                        self.width / 2 - font.GetPointSize() * 3,
+                        self.height / 2 - font.GetPointSize() * 1)
 
         # dc.DrawCircle(50, 50, 1)
 
@@ -187,37 +208,93 @@
 
     def drawtemperature(self, dc, gc, temperature_list,
                         text, text_xoffset, r, g, b, a):
-        if self.timer.IsRunning() is False:
-            dc.SetPen(wx.Pen(wx.Colour(128, 128, 128, 128), 1))
-        else:
-            dc.SetPen(wx.Pen(wx.Colour(r, g, b, a), 1))
+        color = self.timer.IsRunning() and (r, g, b, a) or [128] * 4
+        dc.SetPen(wx.Pen(color, 1))
 
         x_add = float(self.width) / self.xsteps
         x_pos = 0.0
         lastxvalue = 0.0
         lastyvalue = temperature_list[-1]
 
-        for temperature in (temperature_list):
+        for temperature in temperature_list:
             y_pos = self._y_pos(temperature)
-            if (x_pos > 0.0):  # One need 2 points to draw a line.
+            if x_pos > 0:  # One need 2 points to draw a line.
                 dc.DrawLine(lastxvalue, lastyvalue, x_pos, y_pos)
 
             lastxvalue = x_pos
-            x_pos = float(x_pos) + x_add
+            x_pos += x_add
             lastyvalue = y_pos
 
-        if len(text) > 0:
+        if text:
             font = wx.Font(8, wx.DEFAULT, wx.NORMAL, wx.BOLD)
             # font = wx.Font(8, wx.DEFAULT, wx.NORMAL, wx.NORMAL)
-            if self.timer.IsRunning() is False:
-                gc.SetFont(font, wx.Colour(128, 128, 128))
-            else:
-                gc.SetFont(font, wx.Colour(r, g, b))
+            gc.SetFont(font, color[:3])
 
             text_size = len(text) * text_xoffset + 1
-            gc.DrawText(text,
-                        x_pos - x_add - (font.GetPointSize() * text_size),
-                        lastyvalue - (font.GetPointSize() / 2))
+            pos = self.layoutText(text, lastxvalue, lastyvalue, gc)
+            gc.DrawText(text, pos.x, pos.y)
+
+    def layoutRect(self, rc):
+        res = LtRect(rc)
+        reserved = sorted((rs for rs in self.reserved
+            if not (rc.bottom < rs.top or rc.top > rs.bottom)),
+            key=wx.Rect.GetLeft)
+        self.boundRect(res)
+        # search to the left for gaps large enough to accomodate res
+        rci = bisect_left(reserved, res)
+
+        for i in range(rci, len(reserved)-1):
+            res.x = reserved[i].right + 1
+            if res.right < reserved[i+1].left:
+                #found good res
+                break
+        else:
+            # did not find gap to the right
+            if reserved:
+                #try to respect rc.x at the cost of a gap (50...Bed)
+                if res.left < reserved[-1].right:
+                    res.x = reserved[-1].right + 1
+                    if res.right >= self.width:
+                        #goes beyond window bounds
+                        # try to the left
+                        for i in range(min(rci, len(reserved)-1), 0, -1):
+                            res.x = reserved[i].left - rc.width
+                            if reserved[i-1].right < res.left:
+                                break
+                        else:
+                            res = LtRect(self.layoutRectY(rc))
+
+        self.reserved.append(res)
+        return res
+
+    def boundRect(self, rc):
+        rc.x = min(rc.x, self.width - rc.width)
+        return rc
+
+    def layoutRectY(self, rc):
+        top = self.height
+        bottom = 0
+        collision = False
+        res = LtRect(rc)
+        res.x = max(self.gridLabelsRight+1, min(rc.x, self.width-rc.width))
+        for rs in self.reserved:
+            if not (res.right < rs.left or res.left > rs.right):
+                collision = True
+                top = min(top, rs.Top)
+                bottom = max(bottom, rs.bottom)
+        if collision:
+            res.y = top - rc.height
+            if res.y < 0:
+                res.y = bottom+1
+                if res.bottom >= self.height:
+                    res.y = rc.y
+        return res
+
+    def layoutText(self, text, x, y, gc):
+        ext = gc.GetTextExtent(text)
+        rc = self.layoutRect(wx.Rect(x, y, *ext))
+        # print('layoutText', text, rc.TopLeft)
+        return rc
 
     def drawfanpower(self, dc, gc):
         self.drawtemperature(dc, gc, self.fanpowers,
@@ -315,27 +392,37 @@
         self.timer.Start(time)
         if self.window: self.window.graph.StartPlotting(time)
 
+    def Destroy(self):
+        # print(__class__, '.Destroy')
+        self.StopPlotting()
+        return super(BufferedCanvas, self).Destroy()
+
     def StopPlotting(self):
         self.timer.Stop()
-        self.Refresh()
+        #self.Refresh() # do not refresh when stopping in case the underlying object has been destroyed already
         if self.window: self.window.graph.StopPlotting()
 
     def draw(self, dc, w, h):
-        dc.SetBackground(wx.Brush(self.root.bgcolor))
+        dc.SetBackground(wx.Brush(self.root.settings.graph_color_background))
         dc.Clear()
         gc = wx.GraphicsContext.Create(dc)
         self.width = w
         self.height = h
+
+        self.reserved.clear()
         self.drawgrid(dc, gc)
+        self.gridLabelsRight = self.reserved[-1].Right
+
         self.drawbedtargettemp(dc, gc)
         self.drawbedtemp(dc, gc)
         self.drawfanpower(dc, gc)
         self.drawextruder0targettemp(dc, gc)
         self.drawextruder0temp(dc, gc)
-        self.drawextruder1targettemp(dc, gc)
-        self.drawextruder1temp(dc, gc)
+        if self.extruder1targettemps[-1]>0 or self.extruder1temps[-1]>5:
+            self.drawextruder1targettemp(dc, gc)
+            self.drawextruder1temp(dc, gc)
 
-    class _YBounds(object):
+    class _YBounds:
         """Small helper class to claculate y bounds dynamically"""
 
         def __init__(self, graph, minimum_scale=5.0, buffer=0.10):
@@ -402,8 +489,8 @@
             if bed_target > 0 or bed_max > 5:  # use HBP
                 miny = min(miny, bed_min, bed_target)
                 maxy = max(maxy, bed_max, bed_target)
-	    miny=min(0,miny);
-	    maxy=max(260,maxy);
+            miny = min(0, miny)
+            maxy = max(260, maxy)
 
             padding = (maxy - miny) * self.buffer / (1.0 - 2 * self.buffer)
             miny -= padding
@@ -436,8 +523,8 @@
             if bed_target > 0 or bed_max > 5:  # use HBP
                 miny = min(miny, bed_min, bed_target)
                 maxy = max(maxy, bed_max, bed_target)
-	    miny=min(0,miny);
-	    maxy=max(260,maxy);
+            miny = min(0, miny)
+            maxy = max(260, maxy)
 
             # We have to rescale, so add padding
             bufratio = self.buffer / (1.0 - self.buffer)
@@ -450,3 +537,7 @@
 
             return (min(miny, self.graph.minyvalue),
                     max(maxy, self.graph.maxyvalue))
+
+class LtRect(wx.Rect):
+    def __lt__(self, other):
+        return self.x < other.x
--- a/printrun-src/printrun/gui/log.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gui/log.py	Wed Jan 20 10:15:13 2021 +0100
@@ -30,9 +30,17 @@
         lbrs = wx.BoxSizer(wx.HORIZONTAL)
         root.commandbox = wx.TextCtrl(bottom_panel, style = wx.TE_PROCESS_ENTER)
         root.commandbox.SetToolTip(wx.ToolTip(_("Send commands to printer\n(Type 'help' for simple\nhelp function)")))
+        root.commandbox.Hint = 'Command to [S]end'
         root.commandbox.Bind(wx.EVT_TEXT_ENTER, root.sendline)
         root.commandbox.Bind(wx.EVT_CHAR, root.cbkey)
-        root.commandbox.history = [u""]
+        def deselect(ev):
+            # In Ubuntu 19.10, when focused, all text is selected
+            lp = root.commandbox.LastPosition
+            # print(f"SetSelection({lp}, {lp})")
+            wx.CallAfter(root.commandbox.SetSelection, lp, lp)
+            ev.Skip()
+        root.commandbox.Bind(wx.EVT_SET_FOCUS, deselect)
+        root.commandbox.history = [""]
         root.commandbox.histindex = 1
         lbrs.Add(root.commandbox, 1)
         root.sendbtn = make_button(bottom_panel, _("Send"), root.sendline, _("Send Command to Printer"), style = wx.BU_EXACTFIT, container = lbrs)
--- a/printrun-src/printrun/gui/toolbar.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gui/toolbar.py	Wed Jan 20 10:15:13 2021 +0100
@@ -27,7 +27,7 @@
         parentpanel = root.newPanel(parentpanel)
         glob.Add(parentpanel, 1, flag = wx.EXPAND)
         glob.Add(root.locker, 0, flag = wx.ALIGN_CENTER)
-    ToolbarSizer = wx.WrapSizer if use_wrapsizer and wx.VERSION > (2, 9) else wx.BoxSizer
+    ToolbarSizer = wx.WrapSizer if use_wrapsizer else wx.BoxSizer
     self = ToolbarSizer(wx.HORIZONTAL)
     root.rescanbtn = make_autosize_button(parentpanel, _("Port"), root.rescanports, _("Communication Settings\nClick to rescan ports"))
     self.Add(root.rescanbtn, 0, wx.TOP | wx.LEFT, 0)
@@ -42,7 +42,7 @@
     root.baud = wx.ComboBox(parentpanel, -1,
                             choices = ["2400", "9600", "19200", "38400",
                                        "57600", "115200", "250000"],
-                            style = wx.CB_DROPDOWN, size = (100, -1))
+                            style = wx.CB_DROPDOWN, size = (110, -1))
     root.baud.SetToolTip(wx.ToolTip(_("Select Baud rate for printer communication")))
     try:
         root.baud.SetValue("115200")
@@ -52,7 +52,8 @@
     self.Add(root.baud)
 
     if not hasattr(root, "connectbtn"):
-        root.connectbtn = make_autosize_button(parentpanel, _("Connect"), root.connect, _("Connect to the printer"))
+        root.connectbtn_cb_var = root.connect
+        root.connectbtn = make_autosize_button(parentpanel, _("&Connect"), root.connectbtn_cb, _("Connect to the printer"))
         root.statefulControls.append(root.connectbtn)
     else:
         root.connectbtn.Reparent(parentpanel)
--- a/printrun-src/printrun/gui/viz.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gui/viz.py	Wed Jan 20 10:15:13 2021 +0100
@@ -18,10 +18,7 @@
 
 import wx
 
-class NoViz(object):
-
-    showall = False
-
+class BaseViz:
     def clear(self, *a):
         pass
 
@@ -35,19 +32,21 @@
     def addfile(self, *a, **kw):
         pass
 
-    def addgcode(self, *a, **kw):
-        pass
-
     def addgcodehighlight(self, *a, **kw):
         pass
 
-    def Refresh(self, *a):
-        pass
-
     def setlayer(self, *a):
         pass
 
-class NoVizWindow(object):
+    def on_settings_change(self, changed_settings):
+        pass
+
+class NoViz(BaseViz):
+    showall = False
+    def Refresh(self, *a):
+        pass
+
+class NoVizWindow:
 
     def __init__(self):
         self.p = NoViz()
@@ -68,7 +67,13 @@
         if root.settings.mainviz == "3D":
             try:
                 import printrun.gcview
-                root.gviz = printrun.gcview.GcodeViewMainWrapper(parentpanel, root.build_dimensions_list, root = root, circular = root.settings.circular_bed, antialias_samples = int(root.settings.antialias3dsamples))
+                root.gviz = printrun.gcview.GcodeViewMainWrapper(
+                    parentpanel,
+                    root.build_dimensions_list,
+                    root = root,
+                    circular = root.settings.circular_bed,
+                    antialias_samples = int(root.settings.antialias3dsamples),
+                    grid = (root.settings.preview_grid_step1, root.settings.preview_grid_step2))
                 root.gviz.clickcb = root.show_viz_window
             except:
                 use2dview = True
@@ -92,7 +97,14 @@
                 objects = None
                 if isinstance(root.gviz, printrun.gcview.GcodeViewMainWrapper):
                     objects = root.gviz.objects
-                root.gwindow = printrun.gcview.GcodeViewFrame(None, wx.ID_ANY, 'Gcode view, shift to move view, mousewheel to set layer', size = (600, 600), build_dimensions = root.build_dimensions_list, objects = objects, root = root, circular = root.settings.circular_bed, antialias_samples = int(root.settings.antialias3dsamples))
+                root.gwindow = printrun.gcview.GcodeViewFrame(None, wx.ID_ANY, 'Gcode view, shift to move view, mousewheel to set layer',
+                    size = (600, 600),
+                    build_dimensions = root.build_dimensions_list,
+                    objects = objects,
+                    root = root,
+                    circular = root.settings.circular_bed,
+                    antialias_samples = int(root.settings.antialias3dsamples),
+                    grid = (root.settings.preview_grid_step1, root.settings.preview_grid_step2))
             except:
                 use3dview = False
                 logging.error("3D view mode requested, but we failed to initialize it.\n"
@@ -106,4 +118,4 @@
                                            bgcolor = root.bgcolor)
         root.gwindow.Bind(wx.EVT_CLOSE, lambda x: root.gwindow.Hide())
         if not isinstance(root.gviz, NoViz):
-            self.Add(root.gviz.widget, 1, flag = wx.EXPAND | wx.ALIGN_CENTER_HORIZONTAL)
+            self.Add(root.gviz.widget, 1, flag = wx.EXPAND)
--- a/printrun-src/printrun/gui/widgets.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gui/widgets.py	Wed Jan 20 10:15:13 2021 +0100
@@ -120,8 +120,7 @@
                    "UI": _("User interface"),
                    "Viewer": _("Viewer"),
                    "Colors": _("Colors"),
-                   "External": _("External commands"),
-                   "Laser": "Lasercut options"}
+                   "External": _("External commands")}
 
 class PronterOptionsDialog(wx.Dialog):
     """Options editor"""
@@ -131,11 +130,11 @@
         panel = wx.Panel(self)
         header = wx.StaticBox(panel, label = _("Settings"))
         sbox = wx.StaticBoxSizer(header, wx.VERTICAL)
-        notebook = wx.Notebook(panel)
+        self.notebook = notebook = wx.Notebook(panel)
         all_settings = pronterface.settings._all_settings()
         group_list = []
         groups = {}
-        for group in ["Printer", "UI", "Viewer", "Colors", "External", "Laser"]:
+        for group in ["Printer", "UI", "Viewer", "Colors", "External"]:
             group_list.append(group)
             groups[group] = []
         for setting in all_settings:
@@ -162,8 +161,9 @@
                     label.SetFont(font)
                 grid.Add(label, pos = (current_row, 0),
                          flag = wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_RIGHT)
+                expand = 0 if isinstance(widget, (wx.SpinCtrlDouble, wx.Choice, wx.ComboBox)) else wx.EXPAND
                 grid.Add(widget, pos = (current_row, 1),
-                         flag = wx.ALIGN_CENTER_VERTICAL | wx.EXPAND)
+                         flag = wx.ALIGN_CENTER_VERTICAL | expand)
                 if hasattr(label, "set_default"):
                     label.Bind(wx.EVT_MOUSE_EVENTS, label.set_default)
                     if hasattr(widget, "Bind"):
@@ -175,28 +175,25 @@
         panel.SetSizer(sbox)
         topsizer = wx.BoxSizer(wx.VERTICAL)
         topsizer.Add(panel, 1, wx.ALL | wx.EXPAND)
-        topsizer.Add(self.CreateButtonSizer(wx.OK | wx.CANCEL), 0, wx.ALIGN_RIGHT)
+        topsizer.Add(self.CreateButtonSizer(wx.OK | wx.CANCEL), 0, wx.ALIGN_CENTER)
         self.SetSizerAndFit(topsizer)
         self.SetMinSize(self.GetSize())
 
-        self.notebook = notebook
-        self.group_list = group_list
-
-    def setPage(self, name):
-        self.notebook.ChangeSelection(self.group_list.index(name))
-
-def PronterOptions(pronterface, defaulttab = None):
+notebookSelection = 0
+def PronterOptions(pronterface):
     dialog = PronterOptionsDialog(pronterface)
-    if defaulttab:
-        # set the active tab before open dialog
-        dialog.setPage(defaulttab)
-
+    global notebookSelection
+    dialog.notebook.Selection = notebookSelection
     if dialog.ShowModal() == wx.ID_OK:
+        changed_settings = []
         for setting in pronterface.settings._all_settings():
             old_value = setting.value
             setting.update()
             if setting.value != old_value:
                 pronterface.set(setting.name, setting.value)
+                changed_settings.append(setting)
+        pronterface.on_settings_change(changed_settings)
+    notebookSelection = dialog.notebook.Selection
     dialog.Destroy()
 
 class ButtonEdit(wx.Dialog):
@@ -236,7 +233,7 @@
                 valid = True
             elif macro in self.pronterface.macros:
                 valid = True
-            elif hasattr(self.pronterface.__class__, u"do_" + macro):
+            elif hasattr(self.pronterface.__class__, "do_" + macro):
                 valid = False
             elif len([c for c in macro if not c.isalnum() and c != "_"]):
                 valid = False
@@ -268,7 +265,7 @@
         self.Bind(wx.EVT_PAINT, self.paint)
         self.SetBackgroundStyle(wx.BG_STYLE_CUSTOM)
         self.bgcolor = wx.Colour()
-        self.bgcolor.SetFromName(bgcolor)
+        self.bgcolor.Set(bgcolor)
         self.width, self.height = size
         self.title = title
         self.max = maxval
@@ -299,17 +296,17 @@
             lo, hi, val, valhi = cmid, cmax, val - vmid, vmax - vmid
         vv = float(val) / valhi
         rgb = lo.Red() + (hi.Red() - lo.Red()) * vv, lo.Green() + (hi.Green() - lo.Green()) * vv, lo.Blue() + (hi.Blue() - lo.Blue()) * vv
-        rgb = map(lambda x: x * 0.8, rgb)
-        return wx.Colour(*map(int, rgb))
+        rgb = (int(x * 0.8) for x in rgb)
+        return wx.Colour(*rgb)
 
     def paint(self, ev):
-        self.width, self.height = self.GetClientSizeTuple()
+        self.width, self.height = self.GetClientSize()
         self.recalc()
         x0, y0, x1, y1, xE, yE = 1, 1, self.ypt + 1, 1, self.width + 1 - 2, 20
         dc = wx.PaintDC(self)
         dc.SetBackground(wx.Brush(self.bgcolor))
         dc.Clear()
-        cold, medium, hot = wx.Colour(0, 167, 223), wx.Colour(239, 233, 119), wx.Colour(210, 50.100)
+        cold, medium, hot = wx.Colour(0, 167, 223), wx.Colour(239, 233, 119), wx.Colour(210, 50, 0)
         # gauge1, gauge2 = wx.Colour(255, 255, 210), (self.gaugeColour or wx.Colour(234, 82, 0))
         gauge1 = wx.Colour(255, 255, 210)
         shadow1, shadow2 = wx.Colour(110, 110, 110), self.bgcolor
@@ -361,7 +358,7 @@
         setp_path.AddLineToPoint(setpoint, yE - 5)
         gc.DrawPath(setp_path)
         # draw readout
-        text = u"T\u00B0 %u/%u" % (self.value, self.setpoint)
+        text = "T\u00B0 %u/%u" % (self.value, self.setpoint)
         # gc.SetFont(gc.CreateFont(wx.Font(12, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD), wx.WHITE))
         # gc.DrawText(text, 29,-2)
         gc.SetFont(gc.CreateFont(wx.Font(10, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD), wx.WHITE))
@@ -371,7 +368,7 @@
         gc.DrawText(self.title, x0 + 18, y0 + 3)
         gc.DrawText(text, x0 + 118, y0 + 3)
 
-class SpecialButton(object):
+class SpecialButton:
 
     label = None
     command = None
--- a/printrun-src/printrun/gui/xybuttons.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gui/xybuttons.py	Wed Jan 20 10:15:13 2021 +0100
@@ -23,13 +23,36 @@
     elif n > 0: return 1
     else: return 0
 
-class XYButtons(BufferedCanvas):
+DASHES = [4, 7]
+# Brush and pen for grey overlay when mouse hovers over
+HOVER_PEN_COLOR = wx.Colour(100, 100, 100, 172)
+HOVER_BRUSH_COLOR = wx.Colour(0, 0, 0, 128)
+
+class FocusCanvas(BufferedCanvas):
+    def __init__(self, *args, **kwds):
+        super().__init__(*args, **kwds)
+        self.Bind(wx.EVT_SET_FOCUS, self.onFocus)
+    
+    def onFocus(self, evt):
+        self.Refresh()
+        evt.Skip()
+
+    def drawFocusRect(self, dc):
+        if self.HasFocus():
+            pen = wx.Pen(wx.BLACK, 1, wx.PENSTYLE_USER_DASH)
+            pen.SetDashes(DASHES)
+            dc.Pen = pen
+            dc.Brush = wx.Brush(wx.TRANSPARENT_BRUSH)
+            dc.DrawRectangle(self.ClientRect)
+
+class XYButtons(FocusCanvas):
     keypad_positions = {
-        0: (106, 100),
+        0: (104, 99),
         1: (86, 83),
         2: (68, 65),
         3: (53, 50)
     }
+    keypad_radius = 9
     corner_size = (49, 49)
     corner_inset = (7, 13)
     label_overlay_positions = {
@@ -55,6 +78,7 @@
         self.bg_bmp = wx.Image(imagefile(self.imagename), wx.BITMAP_TYPE_PNG).ConvertToBitmap()
         self.keypad_bmp = wx.Image(imagefile("arrow_keys.png"), wx.BITMAP_TYPE_PNG).ConvertToBitmap()
         self.keypad_idx = -1
+        self.hovered_keypad = None
         self.quadrant = None
         self.concentric = None
         self.corner = None
@@ -68,10 +92,10 @@
         self.lastCorner = None
 
         self.bgcolor = wx.Colour()
-        self.bgcolor.SetFromName(bgcolor)
+        self.bgcolor.Set(bgcolor)
         self.bgcolormask = wx.Colour(self.bgcolor.Red(), self.bgcolor.Green(), self.bgcolor.Blue(), 128)
 
-        BufferedCanvas.__init__(self, parent, ID, size=self.bg_bmp.GetSize())
+        super().__init__(parent, ID, size=self.bg_bmp.GetSize())
 
         self.bind_events()
 
@@ -81,15 +105,19 @@
         self.Bind(wx.EVT_LEFT_DCLICK, self.OnLeftDown)
         self.Bind(wx.EVT_MOTION, self.OnMotion)
         self.Bind(wx.EVT_LEAVE_WINDOW, self.OnLeaveWindow)
-        self.Bind(wx.EVT_KEY_UP, self.OnKey)
-        wx.GetTopLevelParent(self).Bind(wx.EVT_CHAR_HOOK, self.OnTopLevelKey)
+        self.Bind(wx.EVT_CHAR_HOOK, self.OnKey)
+        self.Bind(wx.EVT_KILL_FOCUS, self.onKillFocus)
+
+    def onKillFocus(self, evt):
+        self.setKeypadIndex(-1)
+        evt.Skip()
 
     def disable(self):
-        self.enabled = False
+        self.Enabled = self.enabled = False
         self.update()
 
     def enable(self):
-        self.enabled = True
+        self.Enabled = self.enabled = True
         self.update()
 
     def repeatLast(self):
@@ -112,21 +140,23 @@
     def distanceToPoint(self, x1, y1, x2, y2):
         return math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)
 
-    def cycleKeypadIndex(self):
-        idx = self.keypad_idx + 1
-        if idx > 2: idx = 0
-        return idx
+    def cycleKeypadIndex(self, forward):
+        idx = self.keypad_idx + (1 if forward else -1)
+        # do not really cycle to allow exiting of jog controls widget
+        return idx if idx < len(self.keypad_positions) else -1
 
     def setKeypadIndex(self, idx):
         self.keypad_idx = idx
         self.update()
 
-    def getMovement(self):
+    def getMovement(self, event):
         xdir = [1, 0, -1, 0, 0, 0][self.quadrant]
         ydir = [0, 1, 0, -1, 0, 0][self.quadrant]
         zdir = [0, 0, 0, 0, 1, -1][self.quadrant]
         magnitude = math.pow(10, self.concentric - 2)
-        if not zdir == 0:
+        magnitude *= event.ShiftDown() and 2 or event.ControlDown() and 0.5 or 1
+
+        if zdir:
             magnitude = min(magnitude, 10)
         return (magnitude * xdir, magnitude * ydir, magnitude * zdir)
 
@@ -141,7 +171,6 @@
     def getQuadrantConcentricFromPosition(self, pos):
         rel_x = pos[0] - self.center[0]
         rel_y = pos[1] - self.center[1]
-        radius = math.sqrt(rel_x ** 2 + rel_y ** 2)
         if rel_x > rel_y and rel_x > -rel_y:
             quadrant = 0  # Right
         elif rel_x <= rel_y and rel_x > -rel_y:
@@ -151,13 +180,14 @@
         else:
             quadrant = 2  # Left
 
+        radius = math.sqrt(rel_x ** 2 + rel_y ** 2)
         idx = self.lookupConcentric(radius)
         return (quadrant, idx)
 
     def mouseOverKeypad(self, mpos):
         for idx, kpos in self.keypad_positions.items():
             radius = self.distanceToPoint(mpos[0], mpos[1], kpos[0], kpos[1])
-            if radius < 9:
+            if radius < XYButtons.keypad_radius:
                 return idx
         return None
 
@@ -172,7 +202,8 @@
         gc.DrawPath(path)
 
     def highlightQuadrant(self, gc, quadrant, concentric):
-        assert(quadrant >= 0 and quadrant <= 3)
+        if not 0 <= quadrant <= 3:
+            return
         assert(concentric >= 0 and concentric <= 4)
 
         inner_ring_radius = self.concentric_inset
@@ -218,7 +249,7 @@
         w, h = self.corner_size
         xinset, yinset = self.corner_inset
         cx, cy = self.center
-        ww, wh = self.GetSizeTuple()
+        ww, wh = self.GetSize()
 
         if corner == 0:
             x, y = (cx - ww / 2 + xinset + 1, cy - wh / 2 + yinset)
@@ -247,9 +278,8 @@
             gc.DrawBitmap(self.bg_bmp, 0, 0, w, h)
 
         if self.enabled and self.IsEnabled():
-            # Brush and pen for grey overlay when mouse hovers over
-            gc.SetPen(wx.Pen(wx.Colour(100, 100, 100, 172), 4))
-            gc.SetBrush(wx.Brush(wx.Colour(0, 0, 0, 128)))
+            gc.SetPen(wx.Pen(HOVER_PEN_COLOR, 4))
+            gc.SetBrush(wx.Brush(HOVER_BRUSH_COLOR))
 
             if self.concentric is not None:
                 if self.concentric < len(self.concentric_circle_radii):
@@ -266,6 +296,11 @@
                 pos = (pos[0] - padw / 2 - 3, pos[1] - padh / 2 - 3)
                 gc.DrawBitmap(self.keypad_bmp, pos[0], pos[1], padw, padh)
 
+            if self.hovered_keypad is not None and self.hovered_keypad != self.keypad_idx:
+                pos = self.keypad_positions[self.hovered_keypad]
+                r = XYButtons.keypad_radius
+                gc.DrawEllipse(pos[0]-r/2, pos[1]-r/2, r, r)
+
             # Draw label overlays
             gc.SetPen(wx.Pen(wx.Colour(255, 255, 255, 128), 1))
             gc.SetBrush(wx.Brush(wx.Colour(255, 255, 255, 128 + 64)))
@@ -277,6 +312,9 @@
             gc.SetPen(wx.Pen(self.bgcolor, 0))
             gc.SetBrush(wx.Brush(self.bgcolormask))
             gc.DrawRectangle(0, 0, w, h)
+    
+        self.drawFocusRect(dc)
+
         # Used to check exact position of keypad dots, should we ever resize the bg image
         # for idx, kpos in self.label_overlay_positions.items():
         #    dc.DrawCircle(kpos[0], kpos[1], kpos[2])
@@ -284,43 +322,57 @@
     # ------ #
     # Events #
     # ------ #
-    def OnTopLevelKey(self, evt):
-        # Let user press escape on any control, and return focus here
-        if evt.GetKeyCode() == wx.WXK_ESCAPE:
-            self.SetFocus()
-        evt.Skip()
-
     def OnKey(self, evt):
+        # print('XYButtons key', evt.GetKeyCode())
         if not self.enabled:
+            evt.Skip()
             return
+        key = evt.KeyCode
         if self.keypad_idx >= 0:
-            if evt.GetKeyCode() == wx.WXK_TAB:
-                self.setKeypadIndex(self.cycleKeypadIndex())
-            elif evt.GetKeyCode() == wx.WXK_UP:
+            if key == wx.WXK_TAB:
+                keypad = self.cycleKeypadIndex(not evt.ShiftDown())
+                self.setKeypadIndex(keypad)
+                if keypad == -1:
+                    # exit widget after largest step
+                    # evt.Skip()
+                    # On MS Windows if tab event is delivered,
+                    # it is not handled
+                    self.Navigate(not evt.ShiftDown())
+                    return
+            elif key == wx.WXK_ESCAPE:
+                self.setKeypadIndex(-1)
+            elif key == wx.WXK_UP:
                 self.quadrant = 1
-            elif evt.GetKeyCode() == wx.WXK_DOWN:
+            elif key == wx.WXK_DOWN:
                 self.quadrant = 3
-            elif evt.GetKeyCode() == wx.WXK_LEFT:
+            elif key == wx.WXK_LEFT:
                 self.quadrant = 2
-            elif evt.GetKeyCode() == wx.WXK_RIGHT:
+            elif key == wx.WXK_RIGHT:
                 self.quadrant = 0
-            elif evt.GetKeyCode() == wx.WXK_PAGEUP:
+            elif key == wx.WXK_PAGEUP:
                 self.quadrant = 4
-            elif evt.GetKeyCode() == wx.WXK_PAGEDOWN:
+            elif key == wx.WXK_PAGEDOWN:
                 self.quadrant = 5
             else:
                 evt.Skip()
                 return
 
-            self.concentric = self.keypad_idx
-            x, y, z = self.getMovement()
-
-            if x != 0 or y != 0 and self.moveCallback:
-                self.moveCallback(x, y)
-            if z != 0 and self.zCallback:
-                self.zCallback(z)
-        elif evt.GetKeyCode() == wx.WXK_SPACE:
+            self.concentric = self.keypad_idx + 1
+            
+            if self.quadrant is not None:
+                x, y, z = self.getMovement(evt)
+                if (x or y) and self.moveCallback:
+                    self.moveCallback(x, y)
+                if z and self.zCallback:
+                    self.zCallback(z)
+            self.Refresh()
+        elif key == wx.WXK_SPACE:
             self.spacebarCallback()
+        elif key == wx.WXK_TAB:
+            self.setKeypadIndex(len(self.keypad_positions)-1 if evt.ShiftDown() else 0)
+        else:
+            # handle arrows elsewhere
+            evt.Skip()
 
     def OnMotion(self, event):
         if not self.enabled:
@@ -328,12 +380,13 @@
 
         oldcorner = self.corner
         oldq, oldc = self.quadrant, self.concentric
+        old_hovered_keypad = self.hovered_keypad
 
         mpos = event.GetPosition()
-        idx = self.mouseOverKeypad(mpos)
+        self.hovered_keypad = self.mouseOverKeypad(mpos)
         self.quadrant = None
         self.concentric = None
-        if idx is None:
+        if self.hovered_keypad is None:
             center = wx.Point(self.center[0], self.center[1])
             riseDist = self.distanceToLine(mpos, center.x - 1, center.y - 1, center.x + 1, center.y + 1)
             fallDist = self.distanceToLine(mpos, center.x - 1, center.y + 1, center.x + 1, center.y - 1)
@@ -353,7 +406,8 @@
         if mpos.x < cx and mpos.y >= cy:
             self.corner = 3
 
-        if oldq != self.quadrant or oldc != self.concentric or oldcorner != self.corner:
+        if oldq != self.quadrant or oldc != self.concentric or oldcorner != self.corner \
+            or old_hovered_keypad != self.hovered_keypad:
             self.update()
 
     def OnLeftDown(self, event):
@@ -375,7 +429,7 @@
                         self.lastMove = None
                         self.cornerCallback(self.corner_to_axis[-1])
                     elif self.quadrant is not None:
-                        x, y, z = self.getMovement()
+                        x, y, z = self.getMovement(event)
                         if self.moveCallback:
                             self.lastMove = (x, y)
                             self.lastCorner = None
@@ -386,10 +440,7 @@
                         self.lastMove = None
                         self.cornerCallback(self.corner_to_axis[self.corner])
         else:
-            if self.keypad_idx == idx:
-                self.setKeypadIndex(-1)
-            else:
-                self.setKeypadIndex(idx)
+            self.setKeypadIndex(-1 if self.keypad_idx == idx else idx)
 
     def OnLeaveWindow(self, evt):
         self.quadrant = None
@@ -486,9 +537,8 @@
             gc.DrawBitmap(self.bg_bmp, 0, 0, w, h)
 
         if self.enabled and self.IsEnabled():
-            # Brush and pen for grey overlay when mouse hovers over
-            gc.SetPen(wx.Pen(wx.Colour(100, 100, 100, 172), 4))
-            gc.SetBrush(wx.Brush(wx.Colour(0, 0, 0, 128)))
+            gc.SetPen(wx.Pen(HOVER_PEN_COLOR, 4))
+            gc.SetBrush(wx.Brush(HOVER_BRUSH_COLOR))
 
             if self.concentric is not None:
                 if self.concentric < len(self.concentric_circle_radii):
--- a/printrun-src/printrun/gui/zbuttons.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gui/zbuttons.py	Wed Jan 20 10:15:13 2021 +0100
@@ -14,7 +14,7 @@
 # along with Printrun.  If not, see <http://www.gnu.org/licenses/>.
 
 import wx
-from .bufferedcanvas import BufferedCanvas
+from printrun.gui.xybuttons import FocusCanvas
 from printrun.utils import imagefile
 
 def sign(n):
@@ -22,7 +22,7 @@
     elif n > 0: return 1
     else: return 0
 
-class ZButtons(BufferedCanvas):
+class ZButtons(FocusCanvas):
     button_ydistances = [7, 30, 55, 83]  # ,112
     move_values = [0.1, 1, 10]
     center = (30, 118)
@@ -44,22 +44,32 @@
         self.lastValue = None
 
         self.bgcolor = wx.Colour()
-        self.bgcolor.SetFromName(bgcolor)
+        self.bgcolor.Set(bgcolor)
         self.bgcolormask = wx.Colour(self.bgcolor.Red(), self.bgcolor.Green(), self.bgcolor.Blue(), 128)
 
-        BufferedCanvas.__init__(self, parent, ID, size=self.bg_bmp.GetSize())
+        # On MS Windows super(style=WANTS_CHARS) prevents tab cycling
+        # pass empty style explicitly
+        super().__init__(parent, ID, size=self.bg_bmp.GetSize(), style=0)
 
         # Set up mouse and keyboard event capture
         self.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown)
         self.Bind(wx.EVT_LEFT_DCLICK, self.OnLeftDown)
         self.Bind(wx.EVT_MOTION, self.OnMotion)
         self.Bind(wx.EVT_LEAVE_WINDOW, self.OnLeaveWindow)
+        self.Bind(wx.EVT_SET_FOCUS, self.RefreshFocus)
+        self.Bind(wx.EVT_KILL_FOCUS, self.RefreshFocus)
+    
+    def RefreshFocus(self, evt):
+        self.Refresh()
+        evt.Skip()
 
     def disable(self):
+        self.Enabled = False # prevents focus
         self.enabled = False
         self.update()
 
     def enable(self):
+        self.Enabled = True
         self.enabled = True
         self.update()
 
@@ -123,6 +133,7 @@
             gc.SetPen(wx.Pen(self.bgcolor, 0))
             gc.SetBrush(wx.Brush(self.bgcolormask))
             gc.DrawRectangle(0, 0, w, h)
+        self.drawFocusRect(dc)
 
     # ------ #
     # Events #
@@ -146,7 +157,7 @@
 
         mpos = event.GetPosition()
         r, d = self.getRangeDir(mpos)
-        if r >= 0:
+        if r is not None and r >= 0:
             value = d * self.move_values[r]
             if self.moveCallback:
                 self.lastValue = value
--- a/printrun-src/printrun/gviz.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/gviz.py	Wed Jan 20 10:15:13 2021 +0100
@@ -13,7 +13,7 @@
 # You should have received a copy of the GNU General Public License
 # along with Printrun.  If not, see <http://www.gnu.org/licenses/>.
 
-from Queue import Queue
+from queue import Queue
 from collections import deque
 import numpy
 import wx
@@ -38,15 +38,15 @@
 
         vbox = wx.BoxSizer(wx.VERTICAL)
         self.toolbar = wx.ToolBar(panel, -1, style = wx.TB_HORIZONTAL | wx.NO_BORDER | wx.TB_HORZ_TEXT)
-        self.toolbar.AddSimpleTool(1, wx.Image(imagefile('zoom_in.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(), _("Zoom In [+]"), '')
-        self.toolbar.AddSimpleTool(2, wx.Image(imagefile('zoom_out.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(), _("Zoom Out [-]"), '')
+        self.toolbar.AddTool(1, '', wx.Image(imagefile('zoom_in.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(), _("Zoom In [+]"),)
+        self.toolbar.AddTool(2, '', wx.Image(imagefile('zoom_out.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(), _("Zoom Out [-]"))
         self.toolbar.AddSeparator()
-        self.toolbar.AddSimpleTool(3, wx.Image(imagefile('arrow_up.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(), _("Move Up a Layer [U]"), '')
-        self.toolbar.AddSimpleTool(4, wx.Image(imagefile('arrow_down.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(), _("Move Down a Layer [D]"), '')
-        self.toolbar.AddLabelTool(5, " " + _("Reset view"), wx.Image(imagefile('reset.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(), shortHelp = _("Reset view"), longHelp = '')
+        self.toolbar.AddTool(3, '', wx.Image(imagefile('arrow_up.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(), _("Move Up a Layer [U]"))
+        self.toolbar.AddTool(4, '', wx.Image(imagefile('arrow_down.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(), _("Move Down a Layer [D]"))
+        self.toolbar.AddTool(5, " " + _("Reset view"), wx.Image(imagefile('reset.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(), shortHelp = _("Reset view"))
         self.toolbar.AddSeparator()
-        self.toolbar.AddSimpleTool(6, wx.Image(imagefile('inject.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(), shortHelpString = _("Inject G-Code"), longHelpString = _("Insert code at the beginning of this layer"))
-        self.toolbar.AddSimpleTool(7, wx.Image(imagefile('edit.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(), shortHelpString = _("Edit layer"), longHelpString = _("Edit the G-Code of this layer"))
+        self.toolbar.AddTool(6, '', wx.Image(imagefile('inject.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(), wx.NullBitmap, shortHelp = _("Inject G-Code"), longHelp = _("Insert code at the beginning of this layer"))
+        self.toolbar.AddTool(7, '', wx.Image(imagefile('edit.png'), wx.BITMAP_TYPE_PNG).ConvertToBitmap(), wx.NullBitmap, shortHelp = _("Edit layer"), longHelp = _("Edit the G-Code of this layer"))
 
         vbox.Add(self.toolbar, 0, border = 5)
 
@@ -120,7 +120,7 @@
             if self.initpos is not None:
                 self.initpos = None
         elif event.Dragging():
-            e = event.GetPositionTuple()
+            e = event.GetPosition()
             if self.initpos is None:
                 self.initpos = e
                 self.basetrans = self.p.translate
@@ -157,7 +157,8 @@
             if z > 0: self.p.zoom(event.GetX(), event.GetY(), 1.2)
             elif z < 0: self.p.zoom(event.GetX(), event.GetY(), 1 / 1.2)
 
-class Gviz(wx.Panel):
+from printrun.gui.viz import BaseViz
+class Gviz(wx.Panel, BaseViz):
 
     # Mark canvas as dirty when setting showall
     _showall = 0
@@ -197,19 +198,19 @@
         self.arcpen = wx.Pen(wx.Colour(255, 0, 0), penwidth)
         self.travelpen = wx.Pen(wx.Colour(10, 80, 80), penwidth)
         self.hlpen = wx.Pen(wx.Colour(200, 50, 50), penwidth)
-        self.fades = [wx.Pen(wx.Colour(250 - 0.6 ** i * 100, 250 - 0.6 ** i * 100, 200 - 0.4 ** i * 50), penwidth) for i in xrange(6)]
-        self.penslist = [self.mainpen, self.travelpen, self.hlpen] + self.fades
+        self.fades = [wx.Pen(wx.Colour(int(250 - 0.6 ** i * 100), int(250 - 0.6 ** i * 100), int(200 - 0.4 ** i * 50)), penwidth) for i in range(6)]
+        self.penslist = [self.mainpen, self.arcpen, self.travelpen, self.hlpen] + self.fades
         self.bgcolor = wx.Colour()
-        self.bgcolor.SetFromName(bgcolor)
-        self.blitmap = wx.EmptyBitmap(self.GetClientSize()[0], self.GetClientSize()[1], -1)
+        self.bgcolor.Set(bgcolor)
+        self.blitmap = wx.Bitmap(self.GetClientSize()[0], self.GetClientSize()[1], -1)
         self.paint_overlay = None
 
     def inject(self):
-        layer = self.layers.index(self.layerindex)
+        layer = self.layers[self.layerindex]
         injector(self.gcode, self.layerindex, layer)
 
     def editlayer(self):
-        layer = self.layers.index(self.layerindex)
+        layer = self.layers[self.layerindex]
         injector_edit(self.gcode, self.layerindex, layer)
 
     def clearhilights(self):
@@ -275,7 +276,7 @@
 
     def resize(self, event):
         old_basescale = self.basescale
-        width, height = self.GetClientSizeTuple()
+        width, height = self.GetClientSize()
         if width < 1 or height < 1:
             return
         self.size = (width, height)
@@ -312,20 +313,20 @@
                 self.scale[1] * x[5],)
 
     def _drawlines(self, dc, lines, pens):
-        scaled_lines = map(self._line_scaler, lines)
+        scaled_lines = [self._line_scaler(l) for l in lines]
         dc.DrawLineList(scaled_lines, pens)
 
     def _drawarcs(self, dc, arcs, pens):
-        scaled_arcs = map(self._arc_scaler, arcs)
+        scaled_arcs = [self._arc_scaler(a) for a in arcs]
         dc.SetBrush(wx.TRANSPARENT_BRUSH)
         for i in range(len(scaled_arcs)):
-            dc.SetPen(pens[i] if type(pens) == list else pens)
+            dc.SetPen(pens[i] if isinstance(pens, numpy.ndarray) else pens)
             dc.DrawArc(*scaled_arcs[i])
 
     def repaint_everything(self):
         width = self.scale[0] * self.build_dimensions[0]
         height = self.scale[1] * self.build_dimensions[1]
-        self.blitmap = wx.EmptyBitmap(width + 1, height + 1, -1)
+        self.blitmap = wx.Bitmap(width + 1, height + 1, -1)
         dc = wx.MemoryDC()
         dc.SelectObject(self.blitmap)
         dc.SetBackground(wx.Brush((250, 250, 200)))
@@ -333,10 +334,10 @@
         dc.SetPen(wx.Pen(wx.Colour(180, 180, 150)))
         for grid_unit in self.grid:
             if grid_unit > 0:
-                for x in xrange(int(self.build_dimensions[0] / grid_unit) + 1):
+                for x in range(int(self.build_dimensions[0] / grid_unit) + 1):
                     draw_x = self.scale[0] * x * grid_unit
                     dc.DrawLine(draw_x, 0, draw_x, height)
-                for y in xrange(int(self.build_dimensions[1] / grid_unit) + 1):
+                for y in range(int(self.build_dimensions[1] / grid_unit) + 1):
                     draw_y = self.scale[1] * (self.build_dimensions[1] - y * grid_unit)
                     dc.DrawLine(0, draw_y, width, draw_y)
             dc.SetPen(wx.Pen(wx.Colour(0, 0, 0)))
@@ -418,10 +419,10 @@
         self.gcode = gcode
         self.showall = showall
         generator = self.add_parsed_gcodes(gcode)
-        generator_output = generator.next()
+        generator_output = next(generator)
         while generator_output is not None:
             yield generator_output
-            generator_output = generator.next()
+            generator_output = next(generator)
         max_layers = len(self.layers)
         if hasattr(self.parent, "layerslider"):
             self.parent.layerslider.SetRange(0, max_layers - 1)
@@ -430,7 +431,7 @@
 
     def addfile(self, gcode = None, showall = False):
         generator = self.addfile_perlayer(gcode, showall)
-        while generator.next() is not None:
+        while next(generator) is not None:
             continue
 
     def _get_movement(self, start_pos, gline):
@@ -501,7 +502,7 @@
 
                 if line is not None:
                     self.lines[viz_layer].append(line)
-                    self.pens[viz_layer].append(self.mainpen if target[3] != self.lastpos[3] else self.travelpen)
+                    self.pens[viz_layer].append(self.mainpen if target[3] != self.lastpos[3] or gline.extruding else self.travelpen)
                 elif arc is not None:
                     self.arcs[viz_layer].append(arc)
                     self.arcpens[viz_layer].append(self.arcpen)
--- a/printrun-src/printrun/objectplater.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/objectplater.py	Wed Jan 20 10:15:13 2021 +0100
@@ -1,5 +1,3 @@
-#!/usr/bin/env python
-
 # This file is part of the Printrun suite.
 #
 # Printrun is free software: you can redistribute it and/or modify
@@ -35,14 +33,22 @@
     def __init__(self, **kwargs):
         self.destroy_on_done = False
         parent = kwargs.get("parent", None)
-        super(PlaterPanel, self).__init__(parent = parent)
+        super().__init__(parent = parent)
         self.prepare_ui(**kwargs)
 
     def prepare_ui(self, filenames = [], callback = None, parent = None, build_dimensions = None):
         self.filenames = filenames
-        self.mainsizer = wx.BoxSizer(wx.HORIZONTAL)
-        panel = self.menupanel = wx.Panel(self, -1)
+        panel = self.menupanel = wx.Panel(self)
         sizer = self.menusizer = wx.GridBagSizer()
+        # Load button
+        loadbutton = wx.Button(panel, label = _("Load"))
+        loadbutton.Bind(wx.EVT_BUTTON, self.load)
+        sizer.Add(loadbutton, pos = (0, 0), span = (1, 1), flag = wx.EXPAND)
+        # Export button
+        exportbutton = wx.Button(panel, label = _("Export"))
+        exportbutton.Bind(wx.EVT_BUTTON, self.export)
+        sizer.Add(exportbutton, pos = (0, 1), span = (1, 1), flag = wx.EXPAND)
+
         self.l = wx.ListBox(panel)
         sizer.Add(self.l, pos = (1, 0), span = (1, 2), flag = wx.EXPAND)
         sizer.AddGrowableRow(1, 1)
@@ -50,10 +56,6 @@
         clearbutton = wx.Button(panel, label = _("Clear"))
         clearbutton.Bind(wx.EVT_BUTTON, self.clear)
         sizer.Add(clearbutton, pos = (2, 0), span = (1, 2), flag = wx.EXPAND)
-        # Load button
-        loadbutton = wx.Button(panel, label = _("Load"))
-        loadbutton.Bind(wx.EVT_BUTTON, self.load)
-        sizer.Add(loadbutton, pos = (0, 0), span = (1, 1), flag = wx.EXPAND)
         # Snap to Z = 0 button
         snapbutton = wx.Button(panel, label = _("Snap to Z = 0"))
         snapbutton.Bind(wx.EVT_BUTTON, self.snap)
@@ -70,10 +72,6 @@
         autobutton = wx.Button(panel, label = _("Auto arrange"))
         autobutton.Bind(wx.EVT_BUTTON, self.autoplate)
         sizer.Add(autobutton, pos = (5, 0), span = (1, 2), flag = wx.EXPAND)
-        # Export button
-        exportbutton = wx.Button(panel, label = _("Export"))
-        exportbutton.Bind(wx.EVT_BUTTON, self.export)
-        sizer.Add(exportbutton, pos = (0, 1), span = (1, 1), flag = wx.EXPAND)
         if callback is not None:
             donebutton = wx.Button(panel, label = _("Done"))
             donebutton.Bind(wx.EVT_BUTTON, lambda e: self.done(e, callback))
@@ -83,24 +81,22 @@
             sizer.Add(cancelbutton, pos = (6, 1), span = (1, 1), flag = wx.EXPAND)
         self.basedir = "."
         self.models = {}
-        panel.SetSizerAndFit(sizer)
+        panel.SetSizer(sizer)
+        self.mainsizer = wx.BoxSizer(wx.HORIZONTAL)
         self.mainsizer.Add(panel, flag = wx.EXPAND)
         self.SetSizer(self.mainsizer)
-        if build_dimensions:
-            self.build_dimensions = build_dimensions
-        else:
-            self.build_dimensions = [200, 200, 100, 0, 0, 0]
+        self.build_dimensions = build_dimensions or [200, 200, 100, 0, 0, 0]
 
     def set_viewer(self, viewer):
         # Patch handle_rotation on the fly
         if hasattr(viewer, "handle_rotation"):
             def handle_rotation(self, event, orig_handler):
                 if self.initpos is None:
-                    self.initpos = event.GetPositionTuple()
+                    self.initpos = event.GetPosition()
                 else:
                     if event.ShiftDown():
                         p1 = self.initpos
-                        p2 = event.GetPositionTuple()
+                        p2 = event.GetPosition()
                         x1, y1, _ = self.mouse_to_3d(p1[0], p1[1])
                         x2, y2, _ = self.mouse_to_3d(p2[0], p2[1])
                         self.parent.move_shape((x2 - x1, y2 - y1))
@@ -112,12 +108,10 @@
         if hasattr(viewer, "handle_wheel"):
             def handle_wheel(self, event, orig_handler):
                 if event.ShiftDown():
-                    delta = event.GetWheelRotation()
                     angle = 10
-                    if delta > 0:
-                        self.parent.rotate_shape(angle / 2)
-                    else:
-                        self.parent.rotate_shape(-angle / 2)
+                    if event.GetWheelRotation() < 0:
+                        angle = -angle
+                    self.parent.rotate_shape(angle / 2)
                 else:
                     orig_handler(event)
             patch_method(viewer, "handle_wheel", handle_wheel)
@@ -246,8 +240,8 @@
 
     def add_model(self, name, model):
         newname = os.path.split(name.lower())[1]
-        if not isinstance(newname, unicode):
-            newname = unicode(newname, "utf-8")
+        if not isinstance(newname, str):
+            newname = str(newname, "utf-8")
         c = 1
         while newname in self.models:
             newname = os.path.split(name.lower())[1]
--- a/printrun-src/printrun/packer.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/packer.py	Wed Jan 20 10:15:13 2021 +0100
@@ -23,7 +23,7 @@
 import Polygon.Utils
 
 
-class Vector2(object):
+class Vector2:
     """Simple 2d vector / point class."""
 
     def __init__(self, x=0, y=0):
@@ -60,7 +60,7 @@
         )
 
 
-class Rect(object):
+class Rect:
     """Simple rectangle object."""
     def __init__(self, width, height, data={}):
         self.width = width
@@ -110,7 +110,7 @@
         return self.width * self.height
 
 
-class PointList(object):
+class PointList:
     """Methods for transforming a list of points."""
     def __init__(self, points=[]):
         self.points = points
@@ -142,7 +142,7 @@
         return segs
 
 
-class LineSegment(object):
+class LineSegment:
     def __init__(self, start, end):
         self.start = start
         self.end = end
@@ -177,7 +177,7 @@
         return closest_point.distance(point)
 
 
-class Packer(object):
+class Packer:
     def __init__(self):
         self._rects = []
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/printrun-src/printrun/plugins/__init__.py	Wed Jan 20 10:15:13 2021 +0100
@@ -0,0 +1,20 @@
+# This file is part of the Printrun suite.
+#
+# Printrun is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Printrun 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Printrun.  If not, see <http://www.gnu.org/licenses/>.
+
+#from printrun.plugins.sample import SampleHandler
+#
+#PRINTCORE_HANDLER = [SampleHandler()]
+PRINTCORE_HANDLER = []
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/printrun-src/printrun/plugins/sample.py	Wed Jan 20 10:15:13 2021 +0100
@@ -0,0 +1,67 @@
+# This file is part of the Printrun suite.
+#
+# Printrun is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Printrun 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Printrun.  If not, see <http://www.gnu.org/licenses/>.
+
+from printrun.eventhandler import PrinterEventHandler
+
+class SampleHandler(PrinterEventHandler):
+    '''
+    Sample event handler for printcore.
+    '''
+    
+    def __init__(self):
+        pass
+
+    def __write(self, field, text = ""):
+        print("%-15s - %s" % (field, text))
+
+    def on_init(self):
+        self.__write("on_init")
+        
+    def on_send(self, command, gline):
+        self.__write("on_send", command)
+    
+    def on_recv(self, line):
+        self.__write("on_recv", line.strip())
+    
+    def on_connect(self):
+        self.__write("on_connect")
+        
+    def on_disconnect(self):
+        self.__write("on_disconnect")
+    
+    def on_error(self, error):
+        self.__write("on_error", error)
+        
+    def on_online(self):
+        self.__write("on_online")
+        
+    def on_temp(self, line):
+        self.__write("on_temp", line)
+    
+    def on_start(self, resume):
+        self.__write("on_start", "true" if resume else "false")
+        
+    def on_end(self):
+        self.__write("on_end")
+        
+    def on_layerchange(self, layer):
+        self.__write("on_layerchange", "%f" % (layer))
+
+    def on_preprintsend(self, gline, index, mainqueue):
+        self.__write("on_preprintsend", gline)
+    
+    def on_printsend(self, gline):
+        self.__write("on_printsend", gline)
+
--- a/printrun-src/printrun/power/__init__.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/power/__init__.py	Wed Jan 20 10:15:13 2021 +0100
@@ -39,10 +39,16 @@
         inhibit_sleep_token = None
         bus = dbus.SessionBus()
         try:
-            # GNOME uses the right object path, try it first
-            service_name = "org.freedesktop.ScreenSaver"
-            proxy = bus.get_object(service_name,
-                                   "/org/freedesktop/ScreenSaver")
+            if os.environ.get('DESKTOP_SESSION') == "mate":
+                # Mate uses a special service
+                service_name = "org.mate.ScreenSaver"
+                object_path = "/org/mate/ScreenSaver"
+            else:
+                # standard service name
+                service_name = "org.freedesktop.ScreenSaver"
+                object_path = "/org/freedesktop/ScreenSaver"
+            # GNOME and Mate use the right object path, try it first
+            proxy = bus.get_object(service_name, object_path)
             inhibit_sleep_handler = dbus.Interface(proxy, service_name)
             # Do a test run
             token = inhibit_sleep_handler.Inhibit("printrun", "test")
@@ -65,7 +71,7 @@
                 return
             inhibit_sleep_handler.UnInhibit(inhibit_sleep_token)
             inhibit_sleep_token = None
-    except Exception, e:
+    except Exception as e:
         logging.warning("Could not setup DBus for sleep inhibition: %s" % e)
 
         def inhibit_sleep(reason):
@@ -107,7 +113,7 @@
                     set_nice(i, p)
                     high_priority_nice = i
                     break
-                except psutil.AccessDenied, e:
+                except psutil.AccessDenied as e:
                     pass
             set_nice(orig_nice, p)
 
@@ -132,7 +138,7 @@
     def powerset_print_stop():
         reset_priority()
         deinhibit_sleep()
-except ImportError, e:
+except ImportError as e:
     logging.warning("psutil unavailable, could not import power utils:" + str(e))
 
     def powerset_print_start(reason):
--- a/printrun-src/printrun/power/osx.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/power/osx.py	Wed Jan 20 10:15:13 2021 +0100
@@ -41,6 +41,7 @@
         encoding = CoreFoundation.kCFStringEncodingASCII
     except AttributeError:
         encoding = 0x600
+    string = string.encode('ascii')
     cfstring = CoreFoundation.CFStringCreateWithCString(None, string, encoding)
     return objc.pyobjc_id(cfstring.nsstring())
 
--- a/printrun-src/printrun/printcore.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/printcore.py	Wed Jan 20 10:15:13 2021 +0100
@@ -1,5 +1,3 @@
-#!/usr/bin/env python
-
 # This file is part of the Printrun suite.
 #
 # Printrun is free software: you can redistribute it and/or modify
@@ -15,29 +13,36 @@
 # You should have received a copy of the GNU General Public License
 # along with Printrun.  If not, see <http://www.gnu.org/licenses/>.
 
-__version__ = "2015.03.10"
+__version__ = "2.0.0rc7"
 
-from serialWrapper import Serial, SerialException, PARITY_ODD, PARITY_NONE
+import sys
+if sys.version_info.major < 3:
+    print("You need to run this on Python 3")
+    sys.exit(-1)
+
+from serial import Serial, SerialException, PARITY_ODD, PARITY_NONE
 from select import error as SelectError
 import threading
-from Queue import Queue, Empty as QueueEmpty
+from queue import Queue, Empty as QueueEmpty
 import time
 import platform
 import os
-import sys
-stdin, stdout, stderr = sys.stdin, sys.stdout, sys.stderr
-reload(sys).setdefaultencoding('utf8')
-sys.stdin, sys.stdout, sys.stderr = stdin, stdout, stderr
 import logging
 import traceback
 import errno
 import socket
 import re
-from functools import wraps
+import selectors
+from functools import wraps, reduce
 from collections import deque
 from printrun import gcoder
-from .utils import install_locale, decode_utf8
+from .utils import set_utf8_locale, install_locale, decode_utf8
+try:
+    set_utf8_locale()
+except:
+    pass
 install_locale('pronterface')
+from printrun.plugins import PRINTCORE_HANDLER
 
 def locked(f):
     @wraps(f)
@@ -61,6 +66,11 @@
 def disable_hup(port):
     control_ttyhup(port, True)
 
+PR_EOF = None  #printrun's marker for EOF
+PR_AGAIN = b'' #printrun's marker for timeout/no data
+SYS_EOF = b''  #python's marker for EOF
+SYS_AGAIN = None #python's marker for timeout/no data
+
 class printcore():
     def __init__(self, port = None, baud = None, dtr=None):
         """Initializes a printcore instance. Pass the port and baud rate to
@@ -108,12 +118,34 @@
         self.send_thread = None
         self.stop_send_thread = False
         self.print_thread = None
+        self.readline_buf = []
+        self.selector = None
+        self.event_handler = PRINTCORE_HANDLER
+        # Not all platforms need to do this parity workaround, and some drivers
+        # don't support it.  Limit it to platforms that actually require it
+        # here to avoid doing redundant work elsewhere and potentially breaking
+        # things.
+        self.needs_parity_workaround = platform.system() == "linux" and os.path.exists("/etc/debian")
+        for handler in self.event_handler:
+            try: handler.on_init()
+            except: logging.error(traceback.format_exc())
         if port is not None and baud is not None:
             self.connect(port, baud)
         self.xy_feedrate = None
         self.z_feedrate = None
 
+    def addEventHandler(self, handler):
+        '''
+        Adds an event handler.
+        
+        @param handler: The handler to be added.
+        '''
+        self.event_handler.append(handler)
+
     def logError(self, error):
+        for handler in self.event_handler:
+            try: handler.on_error(error)
+            except: logging.error(traceback.format_exc())
         if self.errorcb:
             try: self.errorcb(error)
             except: logging.error(traceback.format_exc())
@@ -135,11 +167,23 @@
                 self.print_thread.join()
             self._stop_sender()
             try:
+                if self.selector is not None:
+                    self.selector.unregister(self.printer_tcp)
+                    self.selector.close()
+                    self.selector = None
+                if self.printer_tcp is not None:
+                    self.printer_tcp.close()
+                    self.printer_tcp = None
                 self.printer.close()
             except socket.error:
+                logger.error(traceback.format_exc())
                 pass
             except OSError:
+                logger.error(traceback.format_exc())
                 pass
+        for handler in self.event_handler:
+            try: handler.on_disconnect()
+            except: logging.error(traceback.format_exc())
         self.printer = None
         self.online = False
         self.printing = False
@@ -160,13 +204,13 @@
             # Connect to socket if "port" is an IP, device if not
             host_regexp = re.compile("^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$")
             is_serial = True
-            if ":" in port:
-                bits = port.split(":")
+            if ":" in self.port:
+                bits = self.port.split(":")
                 if len(bits) == 2:
                     hostname = bits[0]
                     try:
-                        port = int(bits[1])
-                        if host_regexp.match(hostname) and 1 <= port <= 65535:
+                        port_number = int(bits[1])
+                        if host_regexp.match(hostname) and 1 <= port_number <= 65535:
                             is_serial = False
                     except:
                         pass
@@ -178,12 +222,17 @@
                 self.timeout = 0.25
                 self.printer_tcp.settimeout(1.0)
                 try:
-                    self.printer_tcp.connect((hostname, port))
-                    self.printer_tcp.settimeout(self.timeout)
-                    self.printer = self.printer_tcp.makefile()
+                    self.printer_tcp.connect((hostname, port_number))
+                    #a single read timeout raises OSError for all later reads
+                    #probably since python 3.5
+                    #use non blocking instead
+                    self.printer_tcp.settimeout(0)
+                    self.printer = self.printer_tcp.makefile('rwb', buffering=0)
+                    self.selector = selectors.DefaultSelector()
+                    self.selector.register(self.printer_tcp, selectors.EVENT_READ)
                 except socket.error as e:
                     if(e.strerror is None): e.strerror=""
-                    self.logError(_("Could not connect to %s:%s:") % (hostname, port) +
+                    self.logError(_("Could not connect to %s:%s:") % (hostname, port_number) +
                                   "\n" + _("Socket error %s:") % e.errno +
                                   "\n" + e.strerror)
                     self.printer = None
@@ -193,14 +242,20 @@
                 disable_hup(self.port)
                 self.printer_tcp = None
                 try:
-                    self.printer = Serial(port = self.port,
-                                          baudrate = self.baud,
-                                          timeout = 0.25,
-                                          parity = PARITY_ODD)
-                    self.printer.close()
-                    self.printer.parity = PARITY_NONE
+                    if self.needs_parity_workaround:
+                        self.printer = Serial(port = self.port,
+                                              baudrate = self.baud,
+                                              timeout = 0.25,
+                                              parity = PARITY_ODD)
+                        self.printer.close()
+                        self.printer.parity = PARITY_NONE
+                    else:
+                        self.printer = Serial(baudrate = self.baud,
+                                              timeout = 0.25,
+                                              parity = PARITY_NONE)
+                        self.printer.port = self.port
                     try:  #this appears not to work on many platforms, so we're going to call it but not care if it fails
-                        self.printer.setDTR(dtr);
+                        self.printer.dtr = dtr
                     except:
                         #self.logError(_("Could not set DTR on this platform")) #not sure whether to output an error message
                         pass
@@ -215,8 +270,12 @@
                                   "\n" + _("IO error: %s") % e)
                     self.printer = None
                     return
+            for handler in self.event_handler:
+                try: handler.on_connect()
+                except: logging.error(traceback.format_exc())
             self.stop_read_thread = False
-            self.read_thread = threading.Thread(target = self._listen)
+            self.read_thread = threading.Thread(target = self._listen,
+                                                name='read thread')
             self.read_thread.start()
             self._start_sender()
 
@@ -224,43 +283,90 @@
         """Reset the printer
         """
         if self.printer and not self.printer_tcp:
-            self.printer.setDTR(1)
+            self.printer.dtr = 1
             time.sleep(0.2)
-            self.printer.setDTR(0)
+            self.printer.dtr = 0
+
+    def _readline_buf(self):
+        "Try to readline from buffer"
+        if len(self.readline_buf):
+            chunk = self.readline_buf[-1]
+            eol = chunk.find(b'\n')
+            if eol >= 0:
+                line = b''.join(self.readline_buf[:-1]) + chunk[:(eol+1)]
+                self.readline_buf = []
+                if eol + 1 < len(chunk):
+                    self.readline_buf.append(chunk[(eol+1):])
+                return line
+        return PR_AGAIN
+
+    def _readline_nb(self):
+        "Non blocking readline. Socket based files do not support non blocking or timeouting readline"
+        if self.printer_tcp:
+            line = self._readline_buf()
+            if line:
+                return line
+            chunk_size = 256
+            while True:
+                chunk = self.printer.read(chunk_size)
+                if chunk is SYS_AGAIN and self.selector.select(self.timeout):
+                    chunk = self.printer.read(chunk_size)
+                #print('_readline_nb chunk', chunk, type(chunk))
+                if chunk:
+                    self.readline_buf.append(chunk)
+                    line = self._readline_buf()
+                    if line:
+                        return line
+                elif chunk is SYS_AGAIN:
+                    return PR_AGAIN
+                else:
+                    #chunk == b'' means EOF
+                    line = b''.join(self.readline_buf)
+                    self.readline_buf = []
+                    self.stop_read_thread = True
+                    return line if line else PR_EOF
+        else: # serial port
+            return self.printer.readline()
 
     def _readline(self):
         try:
-            try:
-                line = self.printer.readline()
-                if self.printer_tcp and not line:
-                    raise OSError(-1, "Read EOF from socket")
-            except socket.timeout:
-                return ""
+            line_bytes = self._readline_nb()
+            if line_bytes is PR_EOF:
+                self.logError(_("Can't read from printer (disconnected?). line_bytes is None"))
+                return PR_EOF
+            line = line_bytes.decode('utf-8')
 
             if len(line) > 1:
                 self.log.append(line)
+                for handler in self.event_handler:
+                    try: handler.on_recv(line)
+                    except: logging.error(traceback.format_exc())
                 if self.recvcb:
                     try: self.recvcb(line)
                     except: self.logError(traceback.format_exc())
                 if self.loud: logging.info("RECV: %s" % line.rstrip())
             return line
+        except UnicodeDecodeError:
+            self.logError(_("Got rubbish reply from %s at baudrate %s:") % (self.port, self.baud) +
+                              "\n" + _("Maybe a bad baudrate?"))
+            return None
         except SelectError as e:
             if 'Bad file descriptor' in e.args[1]:
-                self.logError(_(u"Can't read from printer (disconnected?) (SelectError {0}): {1}").format(e.errno, decode_utf8(e.strerror)))
+                self.logError(_("Can't read from printer (disconnected?) (SelectError {0}): {1}").format(e.errno, decode_utf8(e.strerror)))
                 return None
             else:
-                self.logError(_(u"SelectError ({0}): {1}").format(e.errno, decode_utf8(e.strerror)))
+                self.logError(_("SelectError ({0}): {1}").format(e.errno, decode_utf8(e.strerror)))
                 raise
         except SerialException as e:
-            self.logError(_(u"Can't read from printer (disconnected?) (SerialException): {0}").format(decode_utf8(str(e))))
+            self.logError(_("Can't read from printer (disconnected?) (SerialException): {0}").format(decode_utf8(str(e))))
             return None
         except socket.error as e:
-            self.logError(_(u"Can't read from printer (disconnected?) (Socket error {0}): {1}").format(e.errno, decode_utf8(e.strerror)))
+            self.logError(_("Can't read from printer (disconnected?) (Socket error {0}): {1}").format(e.errno, decode_utf8(e.strerror)))
             return None
         except OSError as e:
             if e.errno == errno.EAGAIN:  # Not a real error, no data was available
                 return ""
-            self.logError(_(u"Can't read from printer (disconnected?) (OS Error {0}): {1}").format(e.errno, e.strerror))
+            self.logError(_("Can't read from printer (disconnected?) (OS Error {0}): {1}").format(e.errno, e.strerror))
             return None
 
     def _listen_can_continue(self):
@@ -268,7 +374,7 @@
             return not self.stop_read_thread and self.printer
         return (not self.stop_read_thread
                 and self.printer
-                and self.printer.isOpen())
+                and self.printer.is_open)
 
     def _listen_until_online(self):
         while not self.online and self._listen_can_continue():
@@ -296,6 +402,9 @@
                 if line.startswith(tuple(self.greetings)) \
                    or line.startswith('ok') or "T:" in line:
                     self.online = True
+                    for handler in self.event_handler:
+                        try: handler.on_online()
+                        except: logging.error(traceback.format_exc())
                     if self.onlinecb:
                         try: self.onlinecb()
                         except: self.logError(traceback.format_exc())
@@ -310,15 +419,20 @@
         while self._listen_can_continue():
             line = self._readline()
             if line is None:
+                logging.debug('_readline() is None, exiting _listen()')
                 break
             if line.startswith('DEBUG_'):
                 continue
             if line.startswith(tuple(self.greetings)) or line.startswith('ok'):
                 self.clear = True
-            if line.startswith('ok') and "T:" in line and self.tempcb:
-                # callback for temp, status, whatever
-                try: self.tempcb(line)
-                except: self.logError(traceback.format_exc())
+            if line.startswith('ok') and "T:" in line:
+                for handler in self.event_handler:
+                    try: handler.on_temp(line)
+                    except: logging.error(traceback.format_exc())
+                if self.tempcb:
+                    # callback for temp, status, whatever
+                    try: self.tempcb(line)
+                    except: self.logError(traceback.format_exc())
             elif line.startswith('Error'):
                 self.logError(line)
             # Teststrings for resend parsing       # Firmware     exp. result
@@ -336,10 +450,12 @@
                         pass
                 self.clear = True
         self.clear = True
+        logging.debug('Exiting read thread')
 
     def _start_sender(self):
         self.stop_send_thread = False
-        self.send_thread = threading.Thread(target = self._sender)
+        self.send_thread = threading.Thread(target = self._sender,
+                                            name = 'send thread')
         self.send_thread.start()
 
     def _stop_sender(self):
@@ -383,6 +499,7 @@
         self.clear = False
         resuming = (startindex != 0)
         self.print_thread = threading.Thread(target = self._print,
+                                             name = 'print thread',
                                              kwargs = {"resuming": resuming})
         self.print_thread.start()
         return True
@@ -413,17 +530,12 @@
         self.paused = True
         self.printing = False
 
-        # try joining the print thread: enclose it in try/except because we
-        # might be calling it from the thread itself
-        try:
-            self.print_thread.join()
-        except RuntimeError, e:
-            if e.message == "cannot join current thread":
-                pass
-            else:
+        # ';@pause' in the gcode file calls pause from the print thread
+        if not threading.current_thread() is self.print_thread:
+            try:
+                self.print_thread.join()
+            except:
                 self.logError(traceback.format_exc())
-        except:
-            self.logError(traceback.format_exc())
 
         self.print_thread = None
 
@@ -434,35 +546,33 @@
         self.pauseE = self.analyzer.abs_e
         self.pauseF = self.analyzer.current_f
         self.pauseRelative = self.analyzer.relative
+        self.pauseRelativeE = self.analyzer.relative_e
 
     def resume(self):
-        """Resumes a paused print.
-        """
+        """Resumes a paused print."""
         if not self.paused: return False
-        if self.paused:
-            # restores the status
-            self.send_now("G90")  # go to absolute coordinates
+        # restores the status
+        self.send_now("G90")  # go to absolute coordinates
+
+        xyFeed = '' if self.xy_feedrate is None else ' F' + str(self.xy_feedrate)
+        zFeed = '' if self.z_feedrate is None else ' F' + str(self.z_feedrate)
 
-            xyFeedString = ""
-            zFeedString = ""
-            if self.xy_feedrate is not None:
-                xyFeedString = " F" + str(self.xy_feedrate)
-            if self.z_feedrate is not None:
-                zFeedString = " F" + str(self.z_feedrate)
+        self.send_now("G1 X%s Y%s%s" % (self.pauseX, self.pauseY, xyFeed))
+        self.send_now("G1 Z" + str(self.pauseZ) + zFeed)
+        self.send_now("G92 E" + str(self.pauseE))
 
-            self.send_now("G1 X%s Y%s%s" % (self.pauseX, self.pauseY,
-                                            xyFeedString))
-            self.send_now("G1 Z" + str(self.pauseZ) + zFeedString)
-            self.send_now("G92 E" + str(self.pauseE))
-
-            # go back to relative if needed
-            if self.pauseRelative: self.send_now("G91")
-            # reset old feed rate
-            self.send_now("G1 F" + str(self.pauseF))
+        # go back to relative if needed
+        if self.pauseRelative:
+            self.send_now("G91")
+        if self.pauseRelativeE:
+            self.send_now('M83')
+        # reset old feed rate
+        self.send_now("G1 F" + str(self.pauseF))
 
         self.paused = False
         self.printing = True
         self.print_thread = threading.Thread(target = self._print,
+                                             name = 'print thread',
                                              kwargs = {"resuming": True})
         self.print_thread.start()
 
@@ -489,6 +599,9 @@
     def _print(self, resuming = False):
         self._stop_sender()
         try:
+            for handler in self.event_handler:
+                try: handler.on_start(resuming)
+                except: logging.error(traceback.format_exc())
             if self.startcb:
                 # callback for printing started
                 try: self.startcb(resuming)
@@ -500,6 +613,9 @@
             self.sentlines = {}
             self.log.clear()
             self.sent = []
+            for handler in self.event_handler:
+                try: handler.on_end()
+                except: logging.error(traceback.format_exc())
             if self.endcb:
                 # callback for printing done
                 try: self.endcb()
@@ -540,16 +656,25 @@
             self._send(self.priqueue.get_nowait())
             self.priqueue.task_done()
             return
-        if self.printing and self.queueindex < len(self.mainqueue):
+        if self.printing and self.mainqueue.has_index(self.queueindex):
             (layer, line) = self.mainqueue.idxs(self.queueindex)
             gline = self.mainqueue.all_layers[layer][line]
+            if self.queueindex > 0:
+                (prev_layer, prev_line) = self.mainqueue.idxs(self.queueindex - 1)
+                if prev_layer != layer:
+                    for handler in self.event_handler:
+                        try: handler.on_layerchange(layer)
+                        except: logging.error(traceback.format_exc())
             if self.layerchangecb and self.queueindex > 0:
                 (prev_layer, prev_line) = self.mainqueue.idxs(self.queueindex - 1)
                 if prev_layer != layer:
                     try: self.layerchangecb(layer)
                     except: self.logError(traceback.format_exc())
+            for handler in self.event_handler:
+                try: handler.on_preprintsend(gline, self.queueindex, self.mainqueue)
+                except: logging.error(traceback.format_exc())
             if self.preprintsendcb:
-                if self.queueindex + 1 < len(self.mainqueue):
+                if self.mainqueue.has_index(self.queueindex + 1):
                     (next_layer, next_line) = self.mainqueue.idxs(self.queueindex + 1)
                     next_gline = self.mainqueue.all_layers[next_layer][next_line]
                 else:
@@ -571,6 +696,9 @@
             if tline:
                 self._send(tline, self.lineno, True)
                 self.lineno += 1
+                for handler in self.event_handler:
+                    try: handler.on_printsend(gline)
+                    except: logging.error(traceback.format_exc())
                 if self.printsendcb:
                     try: self.printsendcb(gline)
                     except: self.logError(traceback.format_exc())
@@ -603,11 +731,15 @@
                                 "\n" + traceback.format_exc())
             if self.loud:
                 logging.info("SENT: %s" % command)
+
+            for handler in self.event_handler:
+                try: handler.on_send(command, gline)
+                except: logging.error(traceback.format_exc())
             if self.sendcb:
                 try: self.sendcb(command, gline)
                 except: self.logError(traceback.format_exc())
             try:
-                self.printer.write(str(command + "\n"))
+                self.printer.write((command + "\n").encode('ascii'))
                 if self.printer_tcp:
                     try:
                         self.printer.flush()
@@ -616,14 +748,14 @@
                 self.writefailures = 0
             except socket.error as e:
                 if e.errno is None:
-                    self.logError(_(u"Can't write to printer (disconnected ?):") +
+                    self.logError(_("Can't write to printer (disconnected ?):") +
                                   "\n" + traceback.format_exc())
                 else:
-                    self.logError(_(u"Can't write to printer (disconnected?) (Socket error {0}): {1}").format(e.errno, decode_utf8(e.strerror)))
+                    self.logError(_("Can't write to printer (disconnected?) (Socket error {0}): {1}").format(e.errno, decode_utf8(e.strerror)))
                 self.writefailures += 1
             except SerialException as e:
-                self.logError(_(u"Can't write to printer (disconnected?) (SerialException): {0}").format(decode_utf8(str(e))))
+                self.logError(_("Can't write to printer (disconnected?) (SerialException): {0}").format(decode_utf8(str(e))))
                 self.writefailures += 1
             except RuntimeError as e:
-                self.logError(_(u"Socket connection broken, disconnected. ({0}): {1}").format(e.errno, decode_utf8(e.strerror)))
+                self.logError(_("Socket connection broken, disconnected. ({0}): {1}").format(e.errno, decode_utf8(e.strerror)))
                 self.writefailures += 1
--- a/printrun-src/printrun/projectlayer.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/projectlayer.py	Wed Jan 20 10:15:13 2021 +0100
@@ -22,12 +22,11 @@
 import tempfile
 import shutil
 from cairosvg.surface import PNGSurface
-import cStringIO
+import io
 import imghdr
 import copy
 import re
 from collections import OrderedDict
-import itertools
 import math
 
 class DisplayFrame(wx.Frame):
@@ -36,8 +35,8 @@
         self.printer = printer
         self.control_frame = parent
         self.pic = wx.StaticBitmap(self)
-        self.bitmap = wx.EmptyBitmap(*res)
-        self.bbitmap = wx.EmptyBitmap(*res)
+        self.bitmap = wx.Bitmap(*res)
+        self.bbitmap = wx.Bitmap(*res)
         self.slicer = 'bitmap'
         self.dpi = 96
         dc = wx.MemoryDC()
@@ -73,8 +72,8 @@
             pass
 
     def resize(self, res = (1024, 768)):
-        self.bitmap = wx.EmptyBitmap(*res)
-        self.bbitmap = wx.EmptyBitmap(*res)
+        self.bitmap = wx.Bitmap(*res)
+        self.bbitmap = wx.Bitmap(*res)
         dc = wx.MemoryDC()
         dc.SelectObject(self.bbitmap)
         dc.SetBackground(wx.Brush("black"))
@@ -101,9 +100,9 @@
 
                     g = layercopy.find("{http://www.w3.org/2000/svg}g")
                     g.set('transform', 'scale(' + str(self.scale) + ')')
-                    stream = cStringIO.StringIO(PNGSurface.convert(dpi = self.dpi, bytestring = xml.etree.ElementTree.tostring(layercopy)))
+                    stream = io.StringIO(PNGSurface.convert(dpi = self.dpi, bytestring = xml.etree.ElementTree.tostring(layercopy)))
                 else:
-                    stream = cStringIO.StringIO(PNGSurface.convert(dpi = self.dpi, bytestring = xml.etree.ElementTree.tostring(image)))
+                    stream = io.StringIO(PNGSurface.convert(dpi = self.dpi, bytestring = xml.etree.ElementTree.tostring(image)))
 
                 pngImage = wx.ImageFromStream(stream)
 
@@ -132,16 +131,16 @@
             pass
 
     def show_img_delay(self, image):
-        print "Showing", str(time.clock())
+        print("Showing", str(time.clock()))
         self.control_frame.set_current_layer(self.index)
         self.draw_layer(image)
         wx.FutureCall(1000 * self.interval, self.hide_pic_and_rise)
 
     def rise(self):
         if (self.direction == "Top Down"):
-            print "Lowering", str(time.clock())
+            print("Lowering", str(time.clock()))
         else:
-            print "Rising", str(time.clock())
+            print("Rising", str(time.clock()))
 
         if self.printer is not None and self.printer.online:
             self.printer.send_now("G91")
@@ -170,7 +169,7 @@
         wx.FutureCall(1000 * self.pause, self.next_img)
 
     def hide_pic(self):
-        print "Hiding", str(time.clock())
+        print("Hiding", str(time.clock()))
         self.pic.Hide()
 
     def hide_pic_and_rise(self):
@@ -181,11 +180,11 @@
         if not self.running:
             return
         if self.index < len(self.layers):
-            print self.index
+            print(self.index)
             wx.CallAfter(self.show_img_delay, self.layers[self.index])
             self.index += 1
         else:
-            print "end"
+            print("end")
             wx.CallAfter(self.pic.Hide)
             wx.CallAfter(self.Refresh)
 
@@ -278,38 +277,40 @@
         # Left Column
 
         fieldsizer.Add(wx.StaticText(self.panel, -1, "Layer (mm):"), pos = (0, 0), flag = wx.ALIGN_CENTER_VERTICAL)
-        self.thickness = wx.TextCtrl(self.panel, -1, str(self._get_setting("project_layer", "0.1")), size = (80, -1))
+        self.thickness = wx.TextCtrl(self.panel, -1, str(self._get_setting("project_layer", "0.1")), size = (125, -1))
         self.thickness.Bind(wx.EVT_TEXT, self.update_thickness)
         self.thickness.SetHelpText("The thickness of each slice. Should match the value used to slice the model.  SVG files update this value automatically, 3dlp.zip files have to be manually entered.")
         fieldsizer.Add(self.thickness, pos = (0, 1))
 
         fieldsizer.Add(wx.StaticText(self.panel, -1, "Exposure (s):"), pos = (1, 0), flag = wx.ALIGN_CENTER_VERTICAL)
-        self.interval = wx.TextCtrl(self.panel, -1, str(self._get_setting("project_interval", "0.5")), size = (80, -1))
+        self.interval = wx.TextCtrl(self.panel, -1, str(self._get_setting("project_interval", "0.5")), size = (125, -1))
         self.interval.Bind(wx.EVT_TEXT, self.update_interval)
         self.interval.SetHelpText("How long each slice should be displayed.")
         fieldsizer.Add(self.interval, pos = (1, 1))
 
         fieldsizer.Add(wx.StaticText(self.panel, -1, "Blank (s):"), pos = (2, 0), flag = wx.ALIGN_CENTER_VERTICAL)
-        self.pause = wx.TextCtrl(self.panel, -1, str(self._get_setting("project_pause", "0.5")), size = (80, -1))
+        self.pause = wx.TextCtrl(self.panel, -1, str(self._get_setting("project_pause", "0.5")), size = (125, -1))
         self.pause.Bind(wx.EVT_TEXT, self.update_pause)
         self.pause.SetHelpText("The pause length between slices. This should take into account any movement of the Z axis, plus time to prepare the resin surface (sliding, tilting, sweeping, etc).")
         fieldsizer.Add(self.pause, pos = (2, 1))
 
         fieldsizer.Add(wx.StaticText(self.panel, -1, "Scale:"), pos = (3, 0), flag = wx.ALIGN_CENTER_VERTICAL)
-        self.scale = floatspin.FloatSpin(self.panel, -1, value = self._get_setting('project_scale', 1.0), increment = 0.1, digits = 3, size = (80, -1))
-        self.scale.Bind(floatspin.EVT_FLOATSPIN, self.update_scale)
+        self.scale = wx.SpinCtrlDouble(self.panel, -1, initial = self._get_setting('project_scale', 1.0), inc = 0.1, size = (125, -1))
+        self.scale.SetDigits(3)
+        self.scale.Bind(wx.EVT_SPINCTRLDOUBLE, self.update_scale)
         self.scale.SetHelpText("The additional scaling of each slice.")
         fieldsizer.Add(self.scale, pos = (3, 1))
 
         fieldsizer.Add(wx.StaticText(self.panel, -1, "Direction:"), pos = (4, 0), flag = wx.ALIGN_CENTER_VERTICAL)
-        self.direction = wx.ComboBox(self.panel, -1, choices = ["Top Down", "Bottom Up"], value = self._get_setting('project_direction', "Top Down"), size = (80, -1))
+        self.direction = wx.ComboBox(self.panel, -1, choices = ["Top Down", "Bottom Up"], value = self._get_setting('project_direction', "Top Down"), size = (125, -1))
         self.direction.Bind(wx.EVT_COMBOBOX, self.update_direction)
         self.direction.SetHelpText("The direction the Z axis should move. Top Down is where the projector is above the model, Bottom up is where the projector is below the model.")
         fieldsizer.Add(self.direction, pos = (4, 1), flag = wx.ALIGN_CENTER_VERTICAL)
 
         fieldsizer.Add(wx.StaticText(self.panel, -1, "Overshoot (mm):"), pos = (5, 0), flag = wx.ALIGN_CENTER_VERTICAL)
-        self.overshoot = floatspin.FloatSpin(self.panel, -1, value = self._get_setting('project_overshoot', 3.0), increment = 0.1, digits = 1, min_val = 0, size = (80, -1))
-        self.overshoot.Bind(floatspin.EVT_FLOATSPIN, self.update_overshoot)
+        self.overshoot = wx.SpinCtrlDouble(self.panel, -1, initial = self._get_setting('project_overshoot', 3.0), inc = 0.1, min = 0, size = (125, -1))
+        self.overshoot.SetDigits(1)
+        self.overshoot.Bind(wx.EVT_SPINCTRLDOUBLE, self.update_overshoot)
         self.overshoot.SetHelpText("How far the axis should move beyond the next slice position for each slice. For Top Down printers this would dunk the model under the resi and then return. For Bottom Up printers this would raise the base away from the vat and then return.")
         fieldsizer.Add(self.overshoot, pos = (5, 1))
 
@@ -329,38 +330,41 @@
 
         fieldsizer.Add(wx.StaticText(self.panel, -1, "X (px):"), pos = (0, 2), flag = wx.ALIGN_CENTER_VERTICAL)
         projectX = int(math.floor(float(self._get_setting("project_x", 1920))))
-        self.X = wx.SpinCtrl(self.panel, -1, str(projectX), max = 999999, size = (80, -1))
+        self.X = wx.SpinCtrl(self.panel, -1, str(projectX), max = 999999, size = (125, -1))
         self.X.Bind(wx.EVT_SPINCTRL, self.update_resolution)
         self.X.SetHelpText("The projector resolution in the X axis.")
         fieldsizer.Add(self.X, pos = (0, 3))
 
         fieldsizer.Add(wx.StaticText(self.panel, -1, "Y (px):"), pos = (1, 2), flag = wx.ALIGN_CENTER_VERTICAL)
         projectY = int(math.floor(float(self._get_setting("project_y", 1200))))
-        self.Y = wx.SpinCtrl(self.panel, -1, str(projectY), max = 999999, size = (80, -1))
+        self.Y = wx.SpinCtrl(self.panel, -1, str(projectY), max = 999999, size = (125, -1))
         self.Y.Bind(wx.EVT_SPINCTRL, self.update_resolution)
         self.Y.SetHelpText("The projector resolution in the Y axis.")
         fieldsizer.Add(self.Y, pos = (1, 3))
 
         fieldsizer.Add(wx.StaticText(self.panel, -1, "OffsetX (mm):"), pos = (2, 2), flag = wx.ALIGN_CENTER_VERTICAL)
-        self.offset_X = floatspin.FloatSpin(self.panel, -1, value = self._get_setting("project_offset_x", 0.0), increment = 1, digits = 1, size = (80, -1))
-        self.offset_X.Bind(floatspin.EVT_FLOATSPIN, self.update_offset)
+        self.offset_X = wx.SpinCtrlDouble(self.panel, -1, initial = self._get_setting("project_offset_x", 0.0), inc = 1, size = (125, -1))
+        self.offset_X.SetDigits(1)
+        self.offset_X.Bind(wx.EVT_SPINCTRLDOUBLE, self.update_offset)
         self.offset_X.SetHelpText("How far the slice should be offset from the edge in the X axis.")
         fieldsizer.Add(self.offset_X, pos = (2, 3))
 
         fieldsizer.Add(wx.StaticText(self.panel, -1, "OffsetY (mm):"), pos = (3, 2), flag = wx.ALIGN_CENTER_VERTICAL)
-        self.offset_Y = floatspin.FloatSpin(self.panel, -1, value = self._get_setting("project_offset_y", 0.0), increment = 1, digits = 1, size = (80, -1))
-        self.offset_Y.Bind(floatspin.EVT_FLOATSPIN, self.update_offset)
+        self.offset_Y = wx.SpinCtrlDouble(self.panel, -1, initial = self._get_setting("project_offset_y", 0.0), inc = 1, size = (125, -1))
+        self.offset_Y.SetDigits(1)
+        self.offset_Y.Bind(wx.EVT_SPINCTRLDOUBLE, self.update_offset)
         self.offset_Y.SetHelpText("How far the slice should be offset from the edge in the Y axis.")
         fieldsizer.Add(self.offset_Y, pos = (3, 3))
 
         fieldsizer.Add(wx.StaticText(self.panel, -1, "ProjectedX (mm):"), pos = (4, 2), flag = wx.ALIGN_CENTER_VERTICAL)
-        self.projected_X_mm = floatspin.FloatSpin(self.panel, -1, value = self._get_setting("project_projected_x", 505.0), increment = 1, digits = 1, size = (80, -1))
-        self.projected_X_mm.Bind(floatspin.EVT_FLOATSPIN, self.update_projected_Xmm)
+        self.projected_X_mm = wx.SpinCtrlDouble(self.panel, -1, initial = self._get_setting("project_projected_x", 505.0), inc = 1, size = (125, -1))
+        self.projected_X_mm.SetDigits(1)
+        self.projected_X_mm.Bind(wx.EVT_SPINCTRLDOUBLE, self.update_projected_Xmm)
         self.projected_X_mm.SetHelpText("The actual width of the entire projected image. Use the Calibrate grid to show the full size of the projected image, and measure the width at the same level where the slice will be projected onto the resin.")
         fieldsizer.Add(self.projected_X_mm, pos = (4, 3))
 
         fieldsizer.Add(wx.StaticText(self.panel, -1, "Z Axis Speed (mm/min):"), pos = (5, 2), flag = wx.ALIGN_CENTER_VERTICAL)
-        self.z_axis_rate = wx.SpinCtrl(self.panel, -1, str(self._get_setting("project_z_axis_rate", 200)), max = 9999, size = (80, -1))
+        self.z_axis_rate = wx.SpinCtrl(self.panel, -1, str(self._get_setting("project_z_axis_rate", 200)), max = 9999, size = (125, -1))
         self.z_axis_rate.Bind(wx.EVT_SPINCTRL, self.update_z_axis_rate)
         self.z_axis_rate.SetHelpText("Speed of the Z axis in mm/minute. Take into account that slower rates may require a longer pause value.")
         fieldsizer.Add(self.z_axis_rate, pos = (5, 3))
@@ -394,7 +398,8 @@
         first_layer_boxer.Add(self.first_layer, flag = wx.ALIGN_CENTER_VERTICAL)
 
         first_layer_boxer.Add(wx.StaticText(self.panel, -1, " (s):"), flag = wx.ALIGN_CENTER_VERTICAL)
-        self.show_first_layer_timer = floatspin.FloatSpin(self.panel, -1, value=-1, increment = 1, digits = 1, size = (55, -1))
+        self.show_first_layer_timer = wx.SpinCtrlDouble(self.panel, -1, initial = -1, inc = 1, size = (125, -1))
+        self.show_first_layer_timer.SetDigits(1)
         self.show_first_layer_timer.SetHelpText("How long to display the first layer for. -1 = unlimited.")
         first_layer_boxer.Add(self.show_first_layer_timer, flag = wx.ALIGN_CENTER_VERTICAL)
         displaysizer.Add(first_layer_boxer, pos = (0, 6), flag = wx.ALIGN_CENTER_VERTICAL)
@@ -557,9 +562,9 @@
         # them with the original then sorts them. It allows for filenames of the
         # format: abc_1.png, which would be followed by abc_10.png alphabetically.
         os.chdir(self.image_dir)
-        vals = filter(os.path.isfile, os.listdir('.'))
-        keys = map(lambda p: int(re.search('\d+', p).group()), vals)
-        imagefilesDict = dict(itertools.izip(keys, vals))
+        vals = [f for f in os.listdir('.') if os.path.isfile(f)]
+        keys = (int(re.search('\d+', p).group()) for p in vals)
+        imagefilesDict = dict(zip(keys, vals))
         imagefilesOrderedDict = OrderedDict(sorted(imagefilesDict.items(), key = lambda t: t[0]))
 
         for f in imagefilesOrderedDict.values():
@@ -584,8 +589,8 @@
                 layers = self.parse_svg(name)
                 layerHeight = layers[1]
                 self.thickness.SetValue(str(layers[1]))
-                print "Layer thickness detected:", layerHeight, "mm"
-            print len(layers[0]), "layers found, total height", layerHeight * len(layers[0]), "mm"
+                print("Layer thickness detected:", layerHeight, "mm")
+            print(len(layers[0]), "layers found, total height", layerHeight * len(layers[0]), "mm")
             self.layers = layers
             self.set_total_layers(len(layers[0]))
             self.set_current_layer(0)
@@ -624,7 +629,7 @@
             resolution_x_pixels = int(self.X.GetValue())
             resolution_y_pixels = int(self.Y.GetValue())
 
-            gridBitmap = wx.EmptyBitmap(resolution_x_pixels, resolution_y_pixels)
+            gridBitmap = wx.Bitmap(resolution_x_pixels, resolution_y_pixels)
             dc = wx.MemoryDC()
             dc.SelectObject(gridBitmap)
             dc.SetBackground(wx.Brush("black"))
@@ -648,8 +653,8 @@
             gridCountX = int(projectedXmm / 10)
             gridCountY = int(projectedYmm / 10)
 
-            for y in xrange(0, gridCountY + 1):
-                for x in xrange(0, gridCountX + 1):
+            for y in range(0, gridCountY + 1):
+                for x in range(0, gridCountX + 1):
                     dc.DrawLine(0, y * (pixelsYPerMM * 10), resolution_x_pixels, y * (pixelsYPerMM * 10))
                     dc.DrawLine(x * (pixelsXPerMM * 10), 0, x * (pixelsXPerMM * 10), resolution_y_pixels)
 
@@ -660,7 +665,7 @@
     def present_first_layer(self, event):
         if (self.first_layer.GetValue()):
             if not hasattr(self, "layers"):
-                print "No model loaded!"
+                print("No model loaded!")
                 self.first_layer.SetValue(False)
                 return
             self.display_frame.offset = (float(self.offset_X.GetValue()), float(self.offset_Y.GetValue()))
@@ -768,7 +773,7 @@
 
     def start_present(self, event):
         if not hasattr(self, "layers"):
-            print "No model loaded!"
+            print("No model loaded!")
             return
 
         self.pause_button.SetLabel("Pause")
@@ -793,18 +798,18 @@
                                    layer_red = self.layer_red.IsChecked())
 
     def stop_present(self, event):
-        print "Stop"
+        print("Stop")
         self.pause_button.SetLabel("Pause")
         self.set_current_layer(0)
         self.display_frame.running = False
 
     def pause_present(self, event):
         if self.pause_button.GetLabel() == 'Pause':
-            print "Pause"
+            print("Pause")
             self.pause_button.SetLabel("Continue")
             self.display_frame.running = False
         else:
-            print "Continue"
+            print("Continue")
             self.pause_button.SetLabel("Pause")
             self.display_frame.running = True
             self.display_frame.next_img()
--- a/printrun-src/printrun/pronsole.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/pronsole.py	Wed Jan 20 10:15:13 2021 +0100
@@ -1,5 +1,3 @@
-#!/usr/bin/env python
-
 # This file is part of the Printrun suite.
 #
 # Printrun is free software: you can redistribute it and/or modify
@@ -18,6 +16,7 @@
 import cmd
 import glob
 import os
+import platform
 import time
 import threading
 import sys
@@ -30,6 +29,7 @@
 import traceback
 import re
 
+from appdirs import user_cache_dir, user_config_dir, user_data_dir
 from serial import SerialException
 
 from . import printcore
@@ -42,10 +42,11 @@
 from .power import powerset_print_start, powerset_print_stop
 from printrun import gcoder
 from .rpc import ProntRPC
+from printrun.spoolmanager import spoolmanager
 
 if os.name == "nt":
     try:
-        import _winreg
+        import winreg
     except:
         pass
 READLINE = True
@@ -64,8 +65,9 @@
 REPORT_POS = 1
 REPORT_TEMP = 2
 REPORT_MANUAL = 4
+DEG = "\N{DEGREE SIGN}"
 
-class Status(object):
+class Status:
 
     def __init__(self):
         self.extruder_temp = 0
@@ -102,6 +104,37 @@
     def extruder_enabled(self):
         return self.extruder_temp != 0
 
+class RGSGCoder():
+    """Bare alternative to gcoder.LightGCode which does not preload all lines in memory,
+but still allows run_gcode_script (hence the RGS) to be processed by do_print (checksum,threading,ok waiting)"""
+    def __init__(self, line):
+        self.lines = True
+        self.filament_length = 0.
+        self.filament_length_multi = [0]
+        self.proc = run_command(line, {"$s": 'str(self.filename)'}, stdout = subprocess.PIPE, universal_newlines = True)
+        lr = gcoder.Layer([])
+        lr.duration = 0.
+        self.all_layers = [lr]
+        self.read() #empty layer causes division by zero during progress calculation
+    def read(self):
+        ln = self.proc.stdout.readline()
+        if not ln:
+            self.proc.stdout.close()
+            return None
+        ln = ln.strip()
+        if not ln:
+            return None
+        pyLn = gcoder.PyLightLine(ln)
+        self.all_layers[0].append(pyLn)
+        return pyLn
+    def has_index(self, i):
+        while i >= len(self.all_layers[0]) and not self.proc.stdout.closed:
+            self.read()
+        return i < len(self.all_layers[0])
+    def __len__(self):
+        return len(self.all_layers[0])
+    def idxs(self, i):
+        return 0, i #layer, line
 
 class pronsole(cmd.Cmd):
     def __init__(self):
@@ -143,28 +176,30 @@
         self.userm105 = 0
         self.m105_waitcycles = 0
         self.macros = {}
-        self.history_file = "~/.pronsole-history"
         self.rc_loaded = False
         self.processing_rc = False
         self.processing_args = False
         self.settings = Settings(self)
         self.settings._add(BuildDimensionsSetting("build_dimensions", "200x200x100+0+0+0+0+0+0", _("Build dimensions"), _("Dimensions of Build Platform\n & optional offset of origin\n & optional switch position\n\nExamples:\n   XXXxYYY\n   XXX,YYY,ZZZ\n   XXXxYYYxZZZ+OffX+OffY+OffZ\nXXXxYYYxZZZ+OffX+OffY+OffZ+HomeX+HomeY+HomeZ"), "Printer"), self.update_build_dimensions)
         self.settings._port_list = self.scanserial
-        self.settings._temperature_abs_cb = self.set_temp_preset
-        self.settings._temperature_pla_cb = self.set_temp_preset
-        self.settings._bedtemp_abs_cb = self.set_temp_preset
-        self.settings._bedtemp_pla_cb = self.set_temp_preset
         self.update_build_dimensions(None, self.settings.build_dimensions)
         self.update_tcp_streaming_mode(None, self.settings.tcp_streaming_mode)
         self.monitoring = 0
         self.starttime = 0
         self.extra_print_time = 0
         self.silent = False
-        self.commandprefixes = 'MGT$'
+        self.commandprefixes = 'MGTD$'
         self.promptstrs = {"offline": "%(bold)soffline>%(normal)s ",
-                           "fallback": "%(bold)sPC>%(normal)s ",
+                           "fallback": "%(bold)s%(red)s%(port)s%(white)s PC>%(normal)s ",
                            "macro": "%(bold)s..>%(normal)s ",
-                           "online": "%(bold)sT:%(extruder_temp_fancy)s%(progress_fancy)s>%(normal)s "}
+                           "online": "%(bold)s%(green)s%(port)s%(white)s %(extruder_temp_fancy)s%(progress_fancy)s>%(normal)s "}
+        self.spool_manager = spoolmanager.SpoolManager(self)
+        self.current_tool = 0   # Keep track of the extruder being used
+        self.cache_dir = os.path.join(user_cache_dir("Printrun"))
+        self.history_file = os.path.join(self.cache_dir,"history")
+        self.config_dir = os.path.join(user_config_dir("Printrun"))
+        self.data_dir = os.path.join(user_data_dir("Printrun"))
+        self.lineignorepattern=re.compile("ok ?\d*$|.*busy: ?processing|.*busy: ?heating|.*Active Extruder: ?\d*$")
 
     #  --------------------------------------------------------------
     #  General console handling
@@ -196,7 +231,11 @@
                 self.old_completer = readline.get_completer()
                 readline.set_completer(self.complete)
                 readline.parse_and_bind(self.completekey + ": complete")
-                history = os.path.expanduser(self.history_file)
+                history = (self.history_file)
+                if not os.path.exists(history):
+                    if not os.path.exists(self.cache_dir):
+                        os.makedirs(self.cache_dir)
+                    history = os.path.join(self.cache_dir, "history")
                 if os.path.exists(history):
                     readline.read_history_file(history)
             except ImportError:
@@ -213,7 +252,7 @@
                 else:
                     if self.use_rawinput:
                         try:
-                            line = raw_input(self.prompt)
+                            line = input(self.prompt)
                         except EOFError:
                             self.log("")
                             self.do_exit("")
@@ -237,12 +276,12 @@
                 try:
                     import readline
                     readline.set_completer(self.old_completer)
-                    readline.write_history_file(history)
+                    readline.write_history_file(self.history_file)
                 except ImportError:
                     pass
 
     def confirm(self):
-        y_or_n = raw_input("y/n: ")
+        y_or_n = input("y/n: ")
         if y_or_n == "y":
             return True
         elif y_or_n != "n":
@@ -250,11 +289,11 @@
         return False
 
     def log(self, *msg):
-        msg = u"".join(unicode(i) for i in msg)
+        msg = "".join(str(i) for i in msg)
         logging.info(msg)
 
     def logError(self, *msg):
-        msg = u"".join(unicode(i) for i in msg)
+        msg = "".join(str(i) for i in msg)
         logging.error(msg)
         if not self.settings.error_command:
             return
@@ -279,10 +318,12 @@
             specials = {}
             specials["extruder_temp"] = str(int(self.status.extruder_temp))
             specials["extruder_temp_target"] = str(int(self.status.extruder_temp_target))
+            # port: /dev/tty* | netaddress:port
+            specials["port"] = self.settings.port.replace('/dev/', '')
             if self.status.extruder_temp_target == 0:
-                specials["extruder_temp_fancy"] = str(int(self.status.extruder_temp))
+                specials["extruder_temp_fancy"] = str(int(self.status.extruder_temp)) + DEG
             else:
-                specials["extruder_temp_fancy"] = "%s/%s" % (str(int(self.status.extruder_temp)), str(int(self.status.extruder_temp_target)))
+                specials["extruder_temp_fancy"] = "%s%s/%s%s" % (str(int(self.status.extruder_temp)), DEG, str(int(self.status.extruder_temp_target)), DEG)
             if self.p.printing:
                 progress = int(1000 * float(self.p.queueindex) / len(self.p.mainqueue)) / 10
             elif self.sdprinting:
@@ -294,6 +335,9 @@
                 specials["progress_fancy"] = " " + str(progress) + "%"
             else:
                 specials["progress_fancy"] = ""
+            specials["red"] = "\033[31m"
+            specials["green"] = "\033[32m"
+            specials["white"] = "\033[37m"
             specials["bold"] = "\033[01m"
             specials["normal"] = "\033[00m"
             return promptstr % specials
@@ -376,7 +420,7 @@
                 self.log("Setting bed temp to 0")
             self.p.send_now("M140 S0.0")
         self.log("Disconnecting from printer...")
-        if self.p.printing:
+        if self.p.printing and l != "force":
             self.log(_("Are you sure you want to exit while printing?\n\
 (this will terminate the print)."))
             if not self.confirm():
@@ -453,6 +497,7 @@
             self.logError("Empty macro - cancelled")
             return
         macro = None
+        namespace={}
         pycode = "def macro(self,*arg):\n"
         if "\n" not in macro_def.strip():
             pycode += self.compile_macro_line("  " + macro_def.strip())
@@ -460,7 +505,11 @@
             lines = macro_def.split("\n")
             for l in lines:
                 pycode += self.compile_macro_line(l)
-        exec pycode
+        exec(pycode,namespace)
+        try:
+            macro=namespace['macro']
+        except:
+            pass
         return macro
 
     def start_macro(self, macro_name, prev_definition = "", suppress_instructions = False):
@@ -484,7 +533,7 @@
 
     def do_macro(self, args):
         if args.strip() == "":
-            self.print_topics("User-defined macros", map(str, self.macros.keys()), 15, 80)
+            self.print_topics("User-defined macros", [str(k) for k in self.macros.keys()], 15, 80)
             return
         arglist = args.split(None, 1)
         macro_name = arglist[0]
@@ -540,7 +589,7 @@
                 self.save_in_rc("set " + var, "set %s %s" % (var, value))
         except AttributeError:
             logging.debug(_("Unknown variable '%s'") % var)
-        except ValueError, ve:
+        except ValueError as ve:
             if hasattr(ve, "from_validator"):
                 self.logError(_("Bad value %s for variable '%s': %s") % (str, var, ve.args[0]))
             else:
@@ -582,6 +631,7 @@
             self.rc_filename = os.path.abspath(rc_filename)
             for rc_cmd in rc:
                 if not rc_cmd.lstrip().startswith("#"):
+                    logging.debug(rc_cmd.rstrip())
                     self.onecmd(rc_cmd)
             rc.close()
             if hasattr(self, "cur_macro_def"):
@@ -590,17 +640,33 @@
         finally:
             self.processing_rc = False
 
-    def load_default_rc(self, rc_filename = ".pronsolerc"):
-        if rc_filename == ".pronsolerc" and hasattr(sys, "frozen") and sys.frozen in ["windows_exe", "console_exe"]:
-            rc_filename = "printrunconf.ini"
+    def load_default_rc(self):
+        # Check if a configuration file exists in an "old" location,
+        # if not, use the "new" location provided by appdirs
+        for f in '~/.pronsolerc', '~/printrunconf.ini':
+            expanded = os.path.expanduser(f)
+            if os.path.exists(expanded):
+                config = expanded
+                break
+        else:
+            if not os.path.exists(self.config_dir):
+                os.makedirs(self.config_dir)
+
+            config_name = ('printrunconf.ini'
+                            if platform.system() == 'Windows'
+                            else 'pronsolerc')
+
+            config = os.path.join(self.config_dir, config_name)
+        logging.info('Loading config file ' + config)
+
+        # Load the default configuration file
         try:
-            try:
-                self.load_rc(os.path.join(os.path.expanduser("~"), rc_filename))
-            except IOError:
-                self.load_rc(rc_filename)
-        except IOError:
-            # make sure the filename is initialized
-            self.rc_filename = os.path.abspath(os.path.join(os.path.expanduser("~"), rc_filename))
+            self.load_rc(config)
+        except FileNotFoundError:
+            # Make sure the filename is initialized,
+            # and create the file if it doesn't exist
+            self.rc_filename = config
+            open(self.rc_filename, 'a').close()
 
     def save_in_rc(self, key, definition):
         """
@@ -620,9 +686,14 @@
         try:
             written = False
             if os.path.exists(self.rc_filename):
-                shutil.copy(self.rc_filename, self.rc_filename + "~bak")
-                rci = codecs.open(self.rc_filename + "~bak", "r", "utf-8")
-            rco = codecs.open(self.rc_filename + "~new", "w", "utf-8")
+                if not os.path.exists(self.cache_dir):
+                    os.makedirs(self.cache_dir)
+                configcache = os.path.join(self.cache_dir, os.path.basename(self.rc_filename))
+                configcachebak = configcache + "~bak"
+                configcachenew = configcache + "~new"
+                shutil.copy(self.rc_filename, configcachebak)
+                rci = codecs.open(configcachebak, "r", "utf-8")
+            rco = codecs.open(configcachenew, "w", "utf-8")
             if rci is not None:
                 overwriting = False
                 for rc_cmd in rci:
@@ -643,12 +714,12 @@
             if rci is not None:
                 rci.close()
             rco.close()
-            shutil.move(self.rc_filename + "~new", self.rc_filename)
+            shutil.move(configcachenew, self.rc_filename)
             # if definition != "":
             #    self.log("Saved '"+key+"' to '"+self.rc_filename+"'")
             # else:
             #    self.log("Removed '"+key+"' from '"+self.rc_filename+"'")
-        except Exception, e:
+        except Exception as e:
             self.logError("Saving failed for ", key + ":", str(e))
         finally:
             del rci, rco
@@ -688,7 +759,12 @@
             logger = logging.getLogger()
             logger.setLevel(logging.DEBUG)
         for config in args.conf:
-            self.load_rc(config)
+            try:
+                self.load_rc(config)
+            except EnvironmentError as err:
+                print(("ERROR: Unable to load configuration file: %s" %
+                       str(err)[10:]))
+                sys.exit(1)
         if not self.rc_loaded:
             self.load_default_rc()
         self.processing_args = True
@@ -697,8 +773,7 @@
         self.processing_args = False
         self.update_rpc_server(None, self.settings.rpc_server)
         if args.filename:
-            filename = args.filename.decode(locale.getpreferredencoding())
-            self.cmdline_filename_callback(filename)
+            self.cmdline_filename_callback(args.filename)
 
     def cmdline_filename_callback(self, filename):
         self.do_load(filename)
@@ -736,7 +811,8 @@
                 self.logError(traceback.format_exc())
             return False
         self.statuscheck = True
-        self.status_thread = threading.Thread(target = self.statuschecker)
+        self.status_thread = threading.Thread(target = self.statuschecker,
+                                              name = 'status thread')
         self.status_thread.start()
         return True
 
@@ -790,17 +866,19 @@
         baselist = []
         if os.name == "nt":
             try:
-                key = _winreg.OpenKey(_winreg.HKEY_LOCAL_MACHINE, "HARDWARE\\DEVICEMAP\\SERIALCOMM")
+                key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, "HARDWARE\\DEVICEMAP\\SERIALCOMM")
                 i = 0
                 while(1):
-                    baselist += [_winreg.EnumValue(key, i)[1]]
+                    baselist += [winreg.EnumValue(key, i)[1]]
                     i += 1
             except:
                 pass
 
         for g in ['/dev/ttyUSB*', '/dev/ttyACM*', "/dev/tty.*", "/dev/cu.*", "/dev/rfcomm*"]:
             baselist += glob.glob(g)
-        return filter(self._bluetoothSerialFilter, baselist)
+        if(sys.platform!="win32" and self.settings.devicepath):
+            baselist += glob.glob(self.settings.devicepath)
+        return [p for p in baselist if self._bluetoothSerialFilter(p)]
 
     def _bluetoothSerialFilter(self, serial):
         return not ("Bluetooth" in serial or "FireFly" in serial)
@@ -832,7 +910,7 @@
             if self.p.writefailures >= 4:
                 self.logError(_("Disconnecting after 4 failed writes."))
                 self.status_thread = None
-                self.disconnect()
+                self.p.disconnect()
                 return
             if do_monitoring:
                 if self.sdprinting and not self.paused:
@@ -883,7 +961,7 @@
             self.fgcode = gcoder.LightGCode(deferred = True)
         else:
             self.fgcode = gcode
-        self.fgcode.prepare(open(filename, "rU"),
+        self.fgcode.prepare(open(filename, "r", encoding="utf-8"),
                             get_home_pos(self.build_dimensions_list),
                             layer_callback = layer_callback)
         self.fgcode.estimate_duration()
@@ -917,11 +995,11 @@
                 return
         try:
             if settings:
-                command = self.settings.sliceoptscommand
+                command = self.settings.slicecommandpath+self.settings.sliceoptscommand
                 self.log(_("Entering slicer settings: %s") % command)
                 run_command(command, blocking = True)
             else:
-                command = self.settings.slicecommand
+                command = self.settings.slicecommandpath+self.settings.slicecommand
                 stl_name = l[0]
                 gcode_name = stl_name.replace(".stl", "_export.gcode").replace(".STL", "_export.gcode")
                 run_command(command,
@@ -930,7 +1008,7 @@
                             blocking = True)
                 self.log(_("Loading sliced file."))
                 self.do_load(l[0].replace(".stl", "_export.gcode"))
-        except Exception, e:
+        except Exception as e:
             self.logError(_("Slicing failed: %s") % e)
 
     def complete_slice(self, text, line, begidx, endidx):
@@ -1067,7 +1145,7 @@
                 self.log(_("Files on SD card:"))
                 self.log("\n".join(self.sdfiles))
         elif self.sdlisting:
-            self.sdfiles.append(line.strip().lower())
+            self.sdfiles.append(re.sub(" \d+$","",line.strip().lower()))
 
     def _do_ls(self, echo):
         # FIXME: this was 2, but I think it should rather be 0 as in do_upload
@@ -1191,6 +1269,18 @@
                 new_total = self.settings.total_filament_used + self.fgcode.filament_length
                 self.set("total_filament_used", new_total)
 
+                # Update the length of filament in the spools
+                self.spool_manager.refresh()
+                if(len(self.fgcode.filament_length_multi)>1):
+                    for i in enumerate(self.fgcode.filament_length_multi):
+                        if self.spool_manager.getSpoolName(i[0]) != None:
+                            self.spool_manager.editLength(
+                                -i[1], extruder = i[0])
+                else:
+                    if self.spool_manager.getSpoolName(0) != None:
+                            self.spool_manager.editLength(
+                                -self.fgcode.filament_length, extruder = 0)
+
             if not self.settings.final_command:
                 return
             output = get_command_output(self.settings.final_command,
@@ -1202,7 +1292,7 @@
 
     def recvcb_report(self, l):
         isreport = REPORT_NONE
-        if "ok C:" in l or "Count" in l \
+        if "ok C:" in l or " Count " in l \
            or ("X:" in l and len(gcoder.m114_exp.findall(l)) == 6):
             self.posreport = l
             isreport = REPORT_POS
@@ -1260,7 +1350,7 @@
             report_type = self.recvcb_report(l)
             if report_type & REPORT_TEMP:
                 self.status.update_tempreading(l)
-            if l != "ok" and not self.sdlisting \
+            if not self.lineignorepattern.match(l) and l[:4] != "wait" and not self.sdlisting \
                and not self.monitoring and (report_type == REPORT_NONE or report_type & REPORT_MANUAL):
                 if l[:5] == "echo:":
                     l = l[5:].lstrip()
@@ -1332,10 +1422,10 @@
             self.p.send_now("M105")
             time.sleep(0.75)
             if not self.status.bed_enabled:
-                self.log(_("Hotend: %s/%s") % (self.status.extruder_temp, self.status.extruder_temp_target))
+                self.log(_("Hotend: %s%s/%s%s") % (self.status.extruder_temp, DEG, self.status.extruder_temp_target, DEG))
             else:
-                self.log(_("Hotend: %s/%s") % (self.status.extruder_temp, self.status.extruder_temp_target))
-                self.log(_("Bed:    %s/%s") % (self.status.bed_temp, self.status.bed_temp_target))
+                self.log(_("Hotend: %s%s/%s%s") % (self.status.extruder_temp, DEG, self.status.extruder_temp_target, DEG))
+                self.log(_("Bed:    %s%s/%s%s") % (self.status.bed_temp, DEG, self.status.bed_temp_target, DEG))
 
     def help_gettemp(self):
         self.log(_("Read the extruder and bed temperature."))
@@ -1366,7 +1456,7 @@
     def help_settemp(self):
         self.log(_("Sets the hotend temperature to the value entered."))
         self.log(_("Enter either a temperature in celsius or one of the following keywords"))
-        self.log(", ".join([i + "(" + self.temps[i] + ")" for i in self.temps.keys()]))
+        self.log(', '.join('%s (%s)'%kv for kv in self.temps.items()))
 
     def complete_settemp(self, text, line, begidx, endidx):
         if (len(line.split()) == 2 and line[-1] != " ") or (len(line.split()) == 1 and line[-1] == " "):
@@ -1456,6 +1546,7 @@
             if self.p.online:
                 self.p.send_now("T%d" % tool)
                 self.log(_("Using tool %d.") % tool)
+                self.current_tool = tool
             else:
                 self.logError(_("Printer is not online."))
         else:
@@ -1560,6 +1651,12 @@
         self.p.send_now("G1 E" + str(length) + " F" + str(feed))
         self.p.send_now("G90")
 
+        # Update the length of filament in the current spool
+        self.spool_manager.refresh()
+        if self.spool_manager.getSpoolName(self.current_tool) != None:
+            self.spool_manager.editLength(-length,
+                extruder = self.current_tool)
+
     def help_extrude(self):
         self.log(_("Extrudes a length of filament, 5mm by default, or the number of mm given as a parameter"))
         self.log(_("extrude - extrudes 5mm of filament at 300mm/min (5mm/s)"))
@@ -1658,7 +1755,7 @@
             self.onecmd(command)
 
     def do_run_script(self, l):
-        p = run_command(l, {"$s": str(self.filename)}, stdout = subprocess.PIPE)
+        p = run_command(l, {"$s": str(self.filename)}, stdout = subprocess.PIPE, universal_newlines = True)
         for line in p.stdout.readlines():
             self.log("<< " + line.strip())
 
@@ -1666,9 +1763,24 @@
         self.log(_("Runs a custom script. Current gcode filename can be given using $s token."))
 
     def do_run_gcode_script(self, l):
-        p = run_command(l, {"$s": str(self.filename)}, stdout = subprocess.PIPE)
-        for line in p.stdout.readlines():
-            self.onecmd(line.strip())
+        try:
+            self.fgcode = RGSGCoder(l)
+            self.do_print(None)
+        except BaseException as e:
+            self.logError(traceback.format_exc())
 
     def help_run_gcode_script(self):
         self.log(_("Runs a custom script which output gcode which will in turn be executed. Current gcode filename can be given using $s token."))
+
+    def complete_run_gcode_script(self, text, line, begidx, endidx):
+        words = line.split()
+        sep = os.path.sep
+        if len(words) < 2:
+            return ['.' + sep , sep]
+        corrected_text = words[-1] # text arg skips leading '/', include it
+        if corrected_text == '.':
+            return ['./'] # guide user that in linux, PATH does not include . and relative executed scripts must start with ./
+        prefix_len = len(corrected_text) - len(text)
+        res = [((f + sep) if os.path.isdir(f) else f)[prefix_len:] #skip unskipped prefix_len
+                for f in glob.glob(corrected_text + '*')]
+        return res
--- a/printrun-src/printrun/rpc.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/rpc.py	Wed Jan 20 10:15:13 2021 +0100
@@ -13,7 +13,7 @@
 # You should have received a copy of the GNU General Public License
 # along with Printrun.  If not, see <http://www.gnu.org/licenses/>.
 
-from SimpleXMLRPCServer import SimpleXMLRPCServer
+from xmlrpc.server import SimpleXMLRPCServer
 from threading import Thread
 import socket
 import logging
@@ -23,7 +23,7 @@
 
 RPC_PORT = 7978
 
-class ProntRPC(object):
+class ProntRPC:
 
     server = None
 
@@ -45,6 +45,16 @@
                 else:
                     raise
         self.server.register_function(self.get_status, 'status')
+        self.server.register_function(self.set_extruder_temperature,'settemp')
+        self.server.register_function(self.set_bed_temperature,'setbedtemp')
+        self.server.register_function(self.load_file,'load_file')
+        self.server.register_function(self.startprint,'startprint')
+        self.server.register_function(self.pauseprint,'pauseprint')
+        self.server.register_function(self.resumeprint,'resumeprint')
+        self.server.register_function(self.sendhome,'sendhome')
+        self.server.register_function(self.connect,'connect')
+        self.server.register_function(self.disconnect, 'disconnect')
+        self.server.register_function(self.send, 'send')
         self.thread = Thread(target = self.run_server)
         self.thread.start()
 
@@ -76,3 +86,30 @@
                 "temps": temps,
                 "z": z,
                 }
+    def set_extruder_temperature(self, targettemp):
+        if self.pronsole.p.online:
+           self.pronsole.p.send_now("M104 S" + targettemp)
+
+    def set_bed_temperature(self,targettemp):
+        if self.pronsole.p.online:
+           self.pronsole.p.send_now("M140 S" + targettemp)
+
+    def load_file(self,filename):
+        self.pronsole.do_load(filename)
+
+    def startprint(self):
+        self.pronsole.do_print("")
+
+    def pauseprint(self):
+        self.pronsole.do_pause("")
+
+    def resumeprint(self):
+        self.pronsole.do_resume("")
+    def sendhome(self):
+        self.pronsole.do_home("")
+    def connect(self):
+        self.pronsole.do_connect("")
+    def disconnect(self):
+        self.pronsole.do_disconnect("")
+    def send(self, command):
+        self.pronsole.p.send_now(command)
--- a/printrun-src/printrun/settings.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/settings.py	Wed Jan 20 10:15:13 2021 +0100
@@ -15,6 +15,8 @@
 
 import logging
 import traceback
+import os
+import sys
 
 from functools import wraps
 
@@ -30,7 +32,7 @@
             sep = "\n"
             if helptxt.find("\n") >= 0:
                 sep = "\n\n"
-        if self.default is not "":
+        if self.default != "":
             deftxt = _("Default: ")
             resethelp = _("(Control-doubleclick to reset to default value)")
             if len(repr(self.default)) > 10:
@@ -39,11 +41,11 @@
                 deftxt += repr(self.default) + "  " + resethelp
         helptxt += sep + deftxt
         if len(helptxt):
-            widget.SetToolTipString(helptxt)
+            widget.SetToolTip(helptxt)
         return widget
     return decorator
 
-class Setting(object):
+class Setting:
 
     DEFAULT_GROUP = "Printer"
 
@@ -64,15 +66,6 @@
         raise NotImplementedError
     value = property(_get_value, _set_value)
 
-    def set_default(self, e):
-        import wx
-        if e.CmdDown() and e.ButtonDClick() and self.default is not "":
-            confirmation = wx.MessageDialog(None, _("Are you sure you want to reset the setting to the default value: {0!r} ?").format(self.default), _("Confirm set default"), wx.ICON_EXCLAMATION | wx.YES_NO | wx.NO_DEFAULT)
-            if confirmation.ShowModal() == wx.ID_YES:
-                self._set_value(self.default)
-        else:
-            e.Skip()
-
     @setting_add_tooltip
     def get_label(self, parent):
         import wx
@@ -90,6 +83,8 @@
     def update(self):
         raise NotImplementedError
 
+    def validate(self, value): pass
+
     def __str__(self):
         return self.name
 
@@ -117,6 +112,12 @@
     def update(self):
         self.value = self.widget.GetValue()
 
+    def set_default(self, e):
+        if e.CmdDown() and e.ButtonDClick() and self.default != "":
+            self.widget.SetValue(self.default)
+        else:
+            e.Skip()
+
 class StringSetting(wxSetting):
 
     def get_specific_widget(self, parent):
@@ -124,6 +125,31 @@
         self.widget = wx.TextCtrl(parent, -1, str(self.value))
         return self.widget
 
+def wxColorToStr(color, withAlpha = True):
+    # including Alpha seems to be non standard in CSS
+    format = '#{0.red:02X}{0.green:02X}{0.blue:02X}' \
+        + ('{0.alpha:02X}' if withAlpha else '')
+    return format.format(color)
+
+class ColorSetting(wxSetting):
+    def __init__(self, name, default, label = None, help = None, group = None, isRGBA=True):
+        super().__init__(name, default, label, help, group)
+        self.isRGBA = isRGBA
+
+    def validate(self, value):
+        from .utils import check_rgb_color, check_rgba_color
+        validate = check_rgba_color if self.isRGBA else check_rgb_color
+        validate(value)
+
+    def get_specific_widget(self, parent):
+        import wx
+        self.widget = wx.ColourPickerCtrl(parent, colour=wx.Colour(self.value), style=wx.CLRP_USE_TEXTCTRL)
+        self.widget.SetValue = self.widget.SetColour
+        self.widget.LayoutDirection = wx.Layout_RightToLeft
+        return self.widget
+    def update(self):
+        self._value = wxColorToStr(self.widget.Colour, self.isRGBA)
+
 class ComboSetting(wxSetting):
 
     def __init__(self, name, default, choices, label = None, help = None, group = None):
@@ -132,30 +158,66 @@
 
     def get_specific_widget(self, parent):
         import wx
-        self.widget = wx.ComboBox(parent, -1, str(self.value), choices = self.choices, style = wx.CB_DROPDOWN)
+        readonly = isinstance(self.choices, tuple)
+        if readonly:
+            # wx.Choice drops its list on click, no need to click down arrow
+            # which is far to the right because of wx.EXPAND
+            self.widget = wx.Choice(parent, -1, choices = self.choices)
+            self.widget.GetValue = lambda: self.choices[self.widget.Selection]
+            self.widget.SetValue = lambda v: self.widget.SetSelection(self.choices.index(v))
+            self.widget.SetValue(self.value)
+        else:
+            self.widget = wx.ComboBox(parent, -1, str(self.value), choices = self.choices, style = wx.CB_DROPDOWN)
         return self.widget
 
 class SpinSetting(wxSetting):
 
     def __init__(self, name, default, min, max, label = None, help = None, group = None, increment = 0.1):
-        super(SpinSetting, self).__init__(name, default, label, help, group)
+        super().__init__(name, default, label, help, group)
         self.min = min
         self.max = max
         self.increment = increment
 
     def get_specific_widget(self, parent):
-        from wx.lib.agw.floatspin import FloatSpin
-        self.widget = FloatSpin(parent, -1, min_val = self.min, max_val = self.max, digits = 0)
+        import wx
+        self.widget = wx.SpinCtrlDouble(parent, -1, min = self.min, max = self.max)
+        self.widget.SetDigits(0)
         self.widget.SetValue(self.value)
         orig = self.widget.GetValue
         self.widget.GetValue = lambda: int(orig())
         return self.widget
 
+def MySpin(parent, digits, *args, **kw):
+    # in GTK 3.[01], spinner is not large enough to fit text
+    # Could be a class, but use function to avoid load errors if wx
+    # not installed
+    # If native wx.SpinCtrlDouble has problems in different platforms
+    # try agw
+    # from wx.lib.agw.floatspin import FloatSpin
+    import wx
+    sp = wx.SpinCtrlDouble(parent, *args, **kw)
+    # sp = FloatSpin(parent)
+    sp.SetDigits(digits)
+    # sp.SetValue(kw['initial'])
+    def fitValue(ev):
+        text = '%%.%df'% digits % sp.Max
+        # native wx.SpinCtrlDouble does not return good size
+        # in GTK 3.0
+        tex = sp.GetTextExtent(text)
+        tsz = sp.GetSizeFromTextSize(tex.x)
+
+        if sp.MinSize.x < tsz.x:
+            # print('fitValue', getattr(sp, 'setting', None), sp.Value, sp.Digits, tsz.x)
+            sp.MinSize = tsz
+            # sp.Size = tsz
+    # sp.Bind(wx.EVT_TEXT, fitValue)
+    fitValue(None)
+    return sp
+
 class FloatSpinSetting(SpinSetting):
 
     def get_specific_widget(self, parent):
-        from wx.lib.agw.floatspin import FloatSpin
-        self.widget = FloatSpin(parent, -1, value = self.value, min_val = self.min, max_val = self.max, increment = self.increment, digits = 2)
+        self.widget = MySpin(parent, 2, initial = self.value, min = self.min, max = self.max, inc = self.increment)
         return self.widget
 
 class BooleanSetting(wxSetting):
@@ -216,9 +278,12 @@
         import wx
         build_dimensions = parse_build_dimensions(self.value)
         self.widgets = []
-        w = lambda val, m, M: self.widgets.append(FloatSpin(parent, -1, value = val, min_val = m, max_val = M, digits = 2))
-        addlabel = lambda name, pos: self.widget.Add(wx.StaticText(parent, -1, name), pos = pos, flag = wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, border = 5)
-        addwidget = lambda *pos: self.widget.Add(self.widgets[-1], pos = pos, flag = wx.RIGHT, border = 5)
+        def w(val, m, M):
+            self.widgets.append(MySpin(parent, 2, initial = val, min = m, max = M))
+        def addlabel(name, pos):
+            self.widget.Add(wx.StaticText(parent, -1, name), pos = pos, flag = wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, border = 5)
+        def addwidget(*pos):
+            self.widget.Add(self.widgets[-1], pos = pos, flag = wx.RIGHT | wx.EXPAND, border = 5)
         self.widget = wx.GridBagSizer()
         addlabel(_("Width"), (0, 0))
         w(build_dimensions[0], 0, 2000)
@@ -240,20 +305,20 @@
         addwidget(1, 5)
         addlabel(_("X home pos."), (2, 0))
         w(build_dimensions[6], -2000, 2000)
-        self.widget.Add(self.widgets[-1], pos = (2, 1))
+        addwidget(2, 1)
         addlabel(_("Y home pos."), (2, 2))
         w(build_dimensions[7], -2000, 2000)
-        self.widget.Add(self.widgets[-1], pos = (2, 3))
+        addwidget(2, 3)
         addlabel(_("Z home pos."), (2, 4))
         w(build_dimensions[8], -2000, 2000)
-        self.widget.Add(self.widgets[-1], pos = (2, 5))
+        addwidget(2, 5)
         return self.widget
 
     def update(self):
         values = [float(w.GetValue()) for w in self.widgets]
         self.value = "%.02fx%.02fx%.02f%+.02f%+.02f%+.02f%+.02f%+.02f%+.02f" % tuple(values)
 
-class Settings(object):
+class Settings:
     def __baudrate_list(self): return ["2400", "9600", "19200", "38400", "57600", "115200", "250000"]
 
     def __init__(self, root):
@@ -264,15 +329,25 @@
         self._add(BooleanSetting("tcp_streaming_mode", False, _("TCP streaming mode"), _("When using a TCP connection to the printer, the streaming mode will not wait for acks from the printer to send new commands. This will break things such as ETA prediction, but can result in smoother prints.")), root.update_tcp_streaming_mode)
         self._add(BooleanSetting("rpc_server", True, _("RPC server"), _("Enable RPC server to allow remotely querying print status")), root.update_rpc_server)
         self._add(BooleanSetting("dtr", True, _("DTR"), _("Disabling DTR would prevent Arduino (RAMPS) from resetting upon connection"), "Printer"))
-        self._add(SpinSetting("bedtemp_abs", 110, 0, 400, _("Bed temperature for ABS"), _("Heated Build Platform temp for ABS (deg C)"), "Printer"))
-        self._add(SpinSetting("bedtemp_pla", 60, 0, 400, _("Bed temperature for PLA"), _("Heated Build Platform temp for PLA (deg C)"), "Printer"))
-        self._add(SpinSetting("temperature_abs", 230, 0, 400, _("Extruder temperature for ABS"), _("Extruder temp for ABS (deg C)"), "Printer"))
-        self._add(SpinSetting("temperature_pla", 185, 0, 400, _("Extruder temperature for PLA"), _("Extruder temp for PLA (deg C)"), "Printer"))
+        if sys.platform != "win32":
+            self._add(StringSetting("devicepath", "", _("Device name pattern"), _("Custom device pattern: for example /dev/3DP_* "), "Printer"))
+        self._add(SpinSetting("bedtemp_abs", 110, 0, 400, _("Bed temperature for ABS"), _("Heated Build Platform temp for ABS (deg C)"), "Printer"), root.set_temp_preset)
+        self._add(SpinSetting("bedtemp_pla", 60, 0, 400, _("Bed temperature for PLA"), _("Heated Build Platform temp for PLA (deg C)"), "Printer"), root.set_temp_preset)
+        self._add(SpinSetting("temperature_abs", 230, 0, 400, _("Extruder temperature for ABS"), _("Extruder temp for ABS (deg C)"), "Printer"), root.set_temp_preset)
+        self._add(SpinSetting("temperature_pla", 185, 0, 400, _("Extruder temperature for PLA"), _("Extruder temp for PLA (deg C)"), "Printer"), root.set_temp_preset)
         self._add(SpinSetting("xy_feedrate", 3000, 0, 50000, _("X && Y manual feedrate"), _("Feedrate for Control Panel Moves in X and Y (mm/min)"), "Printer"))
         self._add(SpinSetting("z_feedrate", 100, 0, 50000, _("Z manual feedrate"), _("Feedrate for Control Panel Moves in Z (mm/min)"), "Printer"))
         self._add(SpinSetting("e_feedrate", 100, 0, 1000, _("E manual feedrate"), _("Feedrate for Control Panel Moves in Extrusions (mm/min)"), "Printer"))
-        self._add(StringSetting("slicecommand", "python skeinforge/skeinforge_application/skeinforge_utilities/skeinforge_craft.py $s", _("Slice command"), _("Slice command"), "External"))
-        self._add(StringSetting("sliceoptscommand", "python skeinforge/skeinforge_application/skeinforge.py", _("Slicer options command"), _("Slice settings command"), "External"))
+        defaultslicerpath = ""
+        if getattr(sys, 'frozen', False):
+            if sys.platform == "darwin":
+                defaultslicerpath = "/Applications/Slic3r.app/Contents/MacOS/"
+            elif sys.platform == "win32":
+                defaultslicerpath = ".\\slic3r\\"
+        self._add(StringSetting("slicecommandpath", defaultslicerpath, _("Path to slicer"), _("Path to slicer"), "External"))
+        slicer = 'slic3r-console' if sys.platform == 'win32' else 'slic3r'
+        self._add(StringSetting("slicecommand", slicer + ' $s --output $o', _("Slice command"), _("Slice command"), "External"))
+        self._add(StringSetting("sliceoptscommand", "slic3r", _("Slicer options command"), _("Slice settings command"), "External"))
         self._add(StringSetting("start_command", "", _("Start command"), _("Executable to run when the print is started"), "External"))
         self._add(StringSetting("final_command", "", _("Final command"), _("Executable to run when the print is finished"), "External"))
         self._add(StringSetting("error_command", "", _("Error command"), _("Executable to run when an error occurs"), "External"))
@@ -296,6 +371,7 @@
         self._add(HiddenSetting("default_extrusion", 5.0))
         self._add(HiddenSetting("last_extrusion", 5.0))
         self._add(HiddenSetting("total_filament_used", 0.0))
+        self._add(HiddenSetting("spool_list", ""))
 
     _settings = []
 
@@ -316,13 +392,11 @@
             return object.__getattribute__(self, name)
         return getattr(self, "_" + name).value
 
-    def _add(self, setting, callback = None, validate = None,
+    def _add(self, setting, callback = None,
              alias = None, autocomplete_list = None):
         setattr(self, setting.name, setting)
         if callback:
             setattr(self, "__" + setting.name + "_cb", callback)
-        if validate:
-            setattr(self, "__" + setting.name + "_validate", validate)
         if alias:
             setattr(self, "__" + setting.name + "_alias", alias)
         if autocomplete_list:
@@ -335,20 +409,16 @@
             pass
         except AttributeError:
             pass
-        try:
-            getattr(self, "__%s_validate" % key)(value)
-        except AttributeError:
-            pass
+        setting = getattr(self, '_'+key)
+        setting.validate(value)
         t = type(getattr(self, key))
-        if t == bool and value == "False": setattr(self, key, False)
-        else: setattr(self, key, t(value))
+        if t == bool and value == "False":
+            value = False
+        setattr(self, key, t(value))
         try:
-            cb = None
-            try:
-                cb = getattr(self, "__%s_cb" % key)
-            except AttributeError:
-                pass
-            if cb is not None: cb(key, value)
+            cb = getattr(self, "__%s_cb" % key, None)
+            if cb is not None:
+                cb(key, value)
         except:
             logging.warning((_("Failed to run callback after setting \"%s\":") % key) +
                             "\n" + traceback.format_exc())
@@ -360,7 +430,7 @@
         except AttributeError:
             pass
         try:
-            return getattr(self, "__%s_alias" % key)().keys()
+            return list(getattr(self, "__%s_alias" % key)().keys())
         except AttributeError:
             pass
         return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/printrun-src/printrun/spoolmanager/spoolmanager.py	Wed Jan 20 10:15:13 2021 +0100
@@ -0,0 +1,262 @@
+# This file is part of the Printrun suite.
+#
+# Printrun is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Printrun 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Printrun.  If not, see <http://www.gnu.org/licenses/>.
+#
+# Copyright 2017 Rock Storm <rockstorm@gmx.com>
+
+# This module indirectly depends of pronsole and settings but it does not
+# import them
+
+class SpoolManager():
+    """
+    Back-end for the Spool Manager.
+
+    It is expected to be called from an object which has the contents of
+    settings.py and pronsole.py. This way the class is able to '_add' and
+    'set' settings.
+
+    This class basically handles a single variable called '_spool_list'. It is
+    a list of spool_items. A spool_item is in turn a list three elements: a
+    string, a float and an integer. Namely: the name of the spool, the
+    remaining length of filament and the extruder it is loaded to. E.g.:
+
+       spool_item = [string name, float length, int extruder]
+
+       _spool_list = [spool_item spool_1, ... , spool_item spool_n ]
+
+    '_spool_list' is somehow a Nx3 matrix where N is the number of recorded
+    spools. The first column contains the names of the spools, the second the
+    lengths of remaining filament and the third column contains which extruder
+    is the spool loaded for.
+
+    The variable '_spool_list' is saved in the configuration file using a
+    setting with the same name: 'spool_list'. It is saved as a single string.
+    It concatenates every item from the list and separates them by a comma and
+    a space. For instance, if the variable '_spool_list' was:
+
+           _spool_list = [["spool_1", 100.0, 0], ["spool_2", 200.0, -1]]
+
+       The 'spool_list' setting will look like:
+
+           "spool_1, 100.0, 0, spool_2, 200.0, -1"
+    """
+
+    def __init__(self, parent):
+        self.parent = parent
+        self.refresh()
+
+    def refresh(self):
+        """
+        Read the configuration file and populate the list of recorded spools.
+        """
+        self._spool_list = self._readSetting(self.parent.settings.spool_list)
+
+    def add(self, spool_name, spool_length):
+        """Add the given spool to the list of recorded spools."""
+        self._spool_list.append([spool_name, spool_length, -1])
+        self._save()
+
+    def load(self, spool_name, extruder):
+        """Set the extruder field of the given spool item."""
+
+        # If there was a spool already loaded for this extruder unload it
+        previous_spool = self._findByColumn(extruder, 2)
+        if previous_spool != -1:
+            self.unload(extruder)
+
+        # Load the given spool
+        new_spool = self._findByColumn(spool_name, 0)
+        self.remove(spool_name)
+        self._spool_list.append([new_spool[0], new_spool[1], extruder])
+        self._save()
+
+    def remove(self, spool_name):
+        """Remove the given spool item from the list of recorded spools."""
+        spool_item = self._findByColumn(spool_name, 0)
+        self._spool_list.remove(spool_item)
+        self._save()
+
+    def unload(self, extruder):
+        """Set to -1 the extruder field of the spool item currently on."""
+
+        spool_item = self._findByColumn(extruder, 2)
+        if spool_item != -1:
+            self.remove(spool_item[0])
+            self._spool_list.append([spool_item[0], spool_item[1], -1])
+            self._save()
+
+    def isLoaded(self, spool_name):
+        """
+        int isLoaded( string name )
+
+        Return the extruder that the given spool is loaded to. -1 if it is
+        not loaded for any extruder or None if the given name does not match
+        any known spool.
+        """
+
+        spool_item = self._findByColumn(spool_name, 0)
+        if spool_item != -1:
+            return spool_item[2]
+        else:
+            return None
+
+    def isListed(self, spool_name):
+        """Return 'True' if the given spool is on the list."""
+
+        spool_item = self._findByColumn(spool_name, 0)
+        if not spool_item == -1:
+            return True
+        else:
+            return False
+
+    def getSpoolName(self, extruder):
+        """
+        string getSpoolName( int extruder )
+
+        Return the name of the spool loaded for the given extruder.
+        """
+
+        spool_item = self._findByColumn(extruder, 2)
+        if spool_item != -1:
+            return spool_item[0]
+        else:
+            return None
+
+    def getRemainingFilament(self, extruder):
+        """
+        float getRemainingFilament( int extruder )
+
+        Return the name of the spool loaded for the given extruder.
+        """
+
+        spool_item = self._findByColumn(extruder, 2)
+        if spool_item != -1:
+            return spool_item[1]
+        else:
+            return float("NaN")
+
+    def editLength(self, increment, spool_name = None, extruder = -1):
+        """
+        int editLength ( float increment, string spool_name, int extruder )
+
+        Add the given 'increment' amount to the length of filament of the
+        given spool. Spool can be specified either by name or by the extruder
+        it is loaded to.
+        """
+
+        if spool_name != None:
+            spool_item = self._findByColumn(spool_name, 0)
+        elif extruder != -1:
+            spool_item = self._findByColumn(extruder, 2)
+        else:
+            return -1   # Not enough arguments
+
+        if spool_item == -1:
+            return -2   # No spool found for the given name or extruder
+
+        length = spool_item[1] + increment
+        self.remove(spool_item[0])
+        self.add(spool_item[0], length)
+        if spool_item[2] > -1:
+            self.load(spool_item[0], spool_item[2])
+        self._save()
+
+        return 0
+
+    def getExtruderCount(self):
+        """int getExtruderCount()"""
+        return self.parent.settings.extruders
+
+    def getSpoolCount(self):
+        """
+        int getSpoolCount()
+
+        Return the number of currently recorded spools.
+        """
+        return len(self._spool_list)
+
+    def getSpoolList(self):
+        """
+        [N][2] getSpoolList ()
+
+        Returns a list of the recorded spools. Returns a Nx2 matrix where N is
+        the number of recorded spools. The first column contains the names of
+        the spools and the second the lengths of remaining filament.
+        """
+
+        slist = []
+        for i in range(self.getSpoolCount()):
+            item = [self._spool_list[i][0], self._spool_list[i][1]]
+            slist.append(item)
+        return slist
+
+    def _findByColumn(self, data, col = 0):
+        """
+        Find which spool_item from the list contains certain data.
+
+        The 'col' argument specifies in which field from the spool_item to
+        look for. For instance, with the following list:
+
+            _spool_list = [["spool_1",   100.0, 1],
+                           ["spool_2",   200.0, 0],
+                           .
+                           .
+                           .
+                           ["spool_10", 1000.0, 0]]
+
+        A call like: _findByColumn("spool_2", 0)
+
+        Will produce: ["spool_2", 200.0, 0]
+
+        col = 0, would look into the "name's column"
+        col = 1, would look into the "length's column"
+        col = 2, would look into the "extruder's column"
+        """
+
+        for spool_item in self._spool_list:
+            if data == spool_item[col]:
+                return spool_item
+
+        return -1
+
+    def _save(self):
+        """Update the list of recorded spools in the configuration file."""
+        self._setSetting(self._spool_list, "spool_list")
+
+    def _setSetting(self, variable, setting):
+        """
+        Write the given variable to the given setting of the configuration
+        file.
+        """
+        n = 3 # number of fields in spool_item
+        string_list = []
+        for i in range(len(variable)):
+            for j in range(n):
+                string_list.append(str(variable[i][j]))
+        separator = ", "
+        self.parent.set(setting, separator.join(string_list))
+
+    def _readSetting(self, setting):
+        """
+        Return the variable read.
+        """
+        n = 3 # number of fields in spool_item
+        string_list = setting.split(", ")
+        variable = []
+        for i in range(len(string_list)//n):
+            variable.append(
+                [string_list[n*i],
+                 float(string_list[n*i+1]),
+                 int(string_list[n*i+2])])
+        return variable
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/printrun-src/printrun/spoolmanager/spoolmanager_gui.py	Wed Jan 20 10:15:13 2021 +0100
@@ -0,0 +1,639 @@
+# This file is part of the Printrun suite.
+#
+# Printrun is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Printrun 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Printrun.  If not, see <http://www.gnu.org/licenses/>.
+#
+# Copyright 2017 Rock Storm <rockstorm@gmx.com>
+
+import wx
+from . import spoolmanager
+
+class SpoolManagerMainWindow(wx.Frame):
+    """
+    Front-end for the Spool Manager.
+
+    Main window which displays the currently loaded spools and the list of
+    recorded ones with buttons to add, load, edit or delete them.
+    """
+
+    def __init__(self, parent, spool_manager):
+        wx.Frame.__init__(self, parent,
+            title = "Spool Manager",
+            style = wx.DEFAULT_FRAME_STYLE | wx.FRAME_FLOAT_ON_PARENT)
+
+        self.statusbar = self.CreateStatusBar()
+
+        self.SetIcon(parent.GetIcon())
+
+        # Initiate the back-end
+        self.spool_manager = spool_manager
+        self.spool_manager.refresh()
+
+        # Generate the dialogs showing the current spools
+        self.current_spools_dialog = CurrentSpoolDialog(self,
+            self.spool_manager)
+
+        # Generate the list of recorded spools
+        self.spool_list = SpoolListView(self, self.spool_manager)
+
+        # Generate the buttons
+        self.new_button = wx.Button(self, wx.ID_ADD)
+        self.new_button.SetToolTip("Add a new spool")
+        self.edit_button = wx.Button(self, wx.ID_EDIT)
+        self.edit_button.SetToolTip("Edit the selected spool")
+        self.delete_button = wx.Button(self, wx.ID_DELETE)
+        self.delete_button.SetToolTip("Delete the selected spool")
+
+        # "Program" the buttons
+        self.new_button.Bind(wx.EVT_BUTTON, self.onClickAdd)
+        self.edit_button.Bind(wx.EVT_BUTTON, self.onClickEdit)
+        self.delete_button.Bind(wx.EVT_BUTTON, self.onClickDelete)
+
+        # Layout
+        ## Group the buttons
+        self.button_sizer = wx.BoxSizer(wx.VERTICAL)
+        self.button_sizer.Add(self.new_button, 1,
+            wx.FIXED_MINSIZE | wx.ALIGN_CENTER)
+        self.button_sizer.Add(self.edit_button, 1,
+            wx.FIXED_MINSIZE | wx.ALIGN_CENTER)
+        self.button_sizer.Add(self.delete_button, 1,
+            wx.FIXED_MINSIZE | wx.ALIGN_CENTER)
+
+        ## Group the buttons with the spool list
+        self.list_sizer = wx.BoxSizer(wx.HORIZONTAL)
+        self.list_sizer.Add(self.spool_list, 1, wx.EXPAND)
+        self.list_sizer.Add(self.button_sizer, 0, wx.ALIGN_CENTER)
+
+        ## Layout the whole thing
+        self.full_sizer = wx.BoxSizer(wx.VERTICAL)
+        self.full_sizer.Add(self.current_spools_dialog, 0, wx.EXPAND)
+        self.full_sizer.Add(self.list_sizer, 1, wx.ALL | wx.EXPAND, 10)
+
+        self.SetSizerAndFit(self.full_sizer)
+
+    def onClickAdd(self, event):
+        """Open the window for customizing the new spool."""
+        SpoolManagerAddWindow(self).Show(True)
+
+    def onClickLoad(self, event, extruder):
+        """Load the selected spool to the correspondent extruder."""
+
+        # Check whether there is a spool selected
+        spool_index = self.spool_list.GetFirstSelected()
+        if spool_index == -1 :
+            self.statusbar.SetStatusText(
+                "Could not load the spool. No spool selected.")
+            return 0
+        else:
+            spool_name = self.spool_list.GetItemText(spool_index)
+            self.statusbar.SetStatusText("")
+
+        # If selected spool is already loaded, do nothing
+        spool_extruder = self.spool_manager.isLoaded(spool_name)
+        if spool_extruder > -1:
+            self.statusbar.SetStatusText(
+                "Spool '%s' is already loaded for Extruder %d." % 
+                (spool_name, spool_extruder))
+            return 0
+
+        # Load the selected spool and refresh the current spools dialog
+        self.spool_manager.load(spool_name, extruder)
+        self.current_spools_dialog.refreshDialog(self.spool_manager)
+        self.statusbar.SetStatusText(
+            "Loaded spool '%s' for Extruder %d." % (spool_name, extruder))
+
+    def onClickUnload(self, event, extruder):
+        """Unload the spool from the correspondent extruder."""
+
+        spool_name = self.spool_manager.getSpoolName(extruder)
+        if spool_name != None:
+            self.spool_manager.unload(extruder)
+            self.current_spools_dialog.refreshDialog(self.spool_manager)
+            self.statusbar.SetStatusText(
+                "Unloaded spool from Extruder %d." % extruder)
+        else:
+            self.statusbar.SetStatusText(
+                "There is no spool loaded for Extruder %d." % extruder)
+
+    def onClickEdit(self, event):
+        """Open the window for editing the data of the selected spool."""
+
+        # Check whether there is a spool selected
+        spool_index = self.spool_list.GetFirstSelected()
+        if spool_index == -1 :
+            self.statusbar.SetStatusText(
+                "Could not edit the spool. No spool selected.")
+            return 0
+
+        # Open the edit window
+        spool_name = self.spool_list.GetItemText(spool_index)
+        spool_length = self.spool_list.GetItemText(spool_index, 1)
+        SpoolManagerEditWindow(self, spool_name, spool_length).Show(True)
+        self.statusbar.SetStatusText("")
+
+    def onClickDelete(self, event):
+        """Delete the selected spool."""
+
+        # Get the selected spool
+        spool_index = self.spool_list.GetFirstSelected()
+        if spool_index == -1 :
+            self.statusbar.SetStatusText(
+                "Could not delete the spool. No spool selected.")
+            return 0
+        else:
+            spool_name = self.spool_list.GetItemText(spool_index)
+            self.statusbar.SetStatusText("")
+
+        # Ask confirmation for deleting
+        delete_dialog = wx.MessageDialog(self,
+            message = "Are you sure you want to delete the '%s' spool" %
+                spool_name,
+            caption = "Delete Spool",
+            style = wx.YES_NO | wx.ICON_EXCLAMATION)
+
+        if delete_dialog.ShowModal() == wx.ID_YES:
+            # Remove spool
+            self.spool_manager.remove(spool_name)
+            self.spool_list.refreshList(self.spool_manager)
+            self.current_spools_dialog.refreshDialog(self.spool_manager)
+            self.statusbar.SetStatusText(
+                "Deleted spool '%s'." % spool_name)
+
+
+class SpoolListView(wx.ListView):
+    """
+    Custom wxListView object which visualizes the list of available spools.
+    """
+
+    def __init__(self, parent, spool_manager):
+        wx.ListView.__init__(self, parent,
+            style = wx.LC_REPORT | wx.LC_SINGLE_SEL)
+        self.InsertColumn(0, "Spool", width = wx.LIST_AUTOSIZE_USEHEADER)
+        self.InsertColumn(1, "Filament", width = wx.LIST_AUTOSIZE_USEHEADER)
+        self.populateList(spool_manager)
+
+        # "Program" the layout
+        self.Bind(wx.EVT_SIZE, self.onResizeList)
+
+    def populateList(self, spool_manager):
+        """Get the list of recorded spools from the Spool Manager."""
+        spool_list = spool_manager.getSpoolList()
+        for i in range(len(spool_list)):
+            self.Append(spool_list[i])
+
+    def refreshList(self, spool_manager):
+        """Refresh the list by re-reading the Spool Manager list."""
+        self.DeleteAllItems()
+        self.populateList(spool_manager)
+
+    def onResizeList(self, event):
+        list_size = self.GetSize()
+        self.SetColumnWidth(1, -2)
+        filament_column_width = self.GetColumnWidth(1)
+        self.SetColumnWidth(col = 0,
+                            width = list_size.width - filament_column_width)
+        event.Skip()
+
+
+class CurrentSpoolDialog(wx.Panel):
+    """
+    Custom wxStaticText object to display the currently loaded spools and
+    their remaining filament.
+    """
+
+    def __init__(self, parent, spool_manager):
+        wx.Panel.__init__(self, parent)
+        self.parent = parent
+        self.extruders = spool_manager.getExtruderCount()
+
+        full_sizer = wx.BoxSizer(wx.VERTICAL)
+
+        # Calculate the minimum size needed to properly display the
+        # extruder information
+        min_size = self.GetTextExtent("    Remaining filament: 0000000.00")
+
+        # Generate a dialog for every extruder
+        self.extruder_dialog = []
+        load_button = []
+        unload_button = []
+        button_sizer = []
+        dialog_sizer = []
+        for i in range(self.extruders):
+            # Generate the dialog with the spool information
+            self.extruder_dialog.append(
+                wx.StaticText(self, style = wx.ST_ELLIPSIZE_END))
+            self.extruder_dialog[i].SetMinSize(wx.Size(min_size.width, -1))
+
+            # Generate the "load" and "unload" buttons
+            load_button.append(wx.Button(self, label = "Load"))
+            load_button[i].SetToolTip(
+                "Load selected spool for Extruder %d" % i)
+            unload_button.append(wx.Button(self, label = "Unload"))
+            unload_button[i].SetToolTip(
+                "Unload the spool for Extruder %d" % i)
+
+            # "Program" the buttons
+            load_button[i].Bind(wx.EVT_BUTTON,
+                lambda event, extruder=i: parent.onClickLoad(event, extruder))
+            unload_button[i].Bind(wx.EVT_BUTTON,
+                lambda event, extruder=i: parent.onClickUnload(event, extruder))
+
+            # Layout
+            button_sizer.append(wx.BoxSizer(wx.VERTICAL))
+            button_sizer[i].Add(load_button[i], 0,
+                wx.FIXED_MINSIZE | wx.ALIGN_CENTER)
+            button_sizer[i].Add(unload_button[i], 0,
+                wx.FIXED_MINSIZE | wx.ALIGN_CENTER)
+
+            dialog_sizer.append(wx.BoxSizer(wx.HORIZONTAL))
+            dialog_sizer[i].Add(self.extruder_dialog[i], 1, wx.ALIGN_CENTER)
+            dialog_sizer[i].AddSpacer(10)
+            dialog_sizer[i].Add(button_sizer[i], 0, wx.EXPAND)
+
+            full_sizer.Add(dialog_sizer[i], 0, wx.ALL | wx.EXPAND, 10)
+
+        self.refreshDialog(spool_manager)
+
+        self.SetSizerAndFit(full_sizer)
+
+
+    def refreshDialog(self, spool_manager):
+        """Retrieve the current spools from the Spool Manager."""
+
+        for i in range(self.extruders):
+            spool_name = spool_manager.getSpoolName(i)
+            spool_filament = spool_manager.getRemainingFilament(i)
+            label = ("Spool for Extruder %d:\n" % i +
+                     "    Name:               %s\n" % spool_name +
+                     "    Remaining filament: %.2f" % spool_filament)
+            self.extruder_dialog[i].SetLabelText(label)
+
+
+# ---------------------------------------------------------------------------
+def checkOverwrite(parent, spool_name):
+    """Ask the user whether or not to overwrite the existing spool."""
+
+    overwrite_dialog = wx.MessageDialog(parent,
+        message = "A spool with the name '%s'' already exists." %
+            spool_name +
+            "Do you wish to overwrite it?",
+        caption = "Overwrite",
+        style = wx.YES_NO | wx.ICON_EXCLAMATION)
+
+    if overwrite_dialog.ShowModal() == wx.ID_YES:
+        return True
+    else:
+        return False
+
+def getFloat(parent, number):
+    """
+    Check whether the input number is a float. Either return the number or
+    return False.
+    """
+    try:
+        return float(number)
+    except ValueError:
+        parent.statusbar.SetStatusText("Unrecognized number: %s" % number)
+        return False
+
+
+# ---------------------------------------------------------------------------
+class SpoolManagerAddWindow(wx.Frame):
+    """Window for adding spools."""
+
+    def __init__(self, parent):
+
+        wx.Frame.__init__(self, parent,
+            title = "Add Spool",
+            style = wx.DEFAULT_FRAME_STYLE | wx.FRAME_FLOAT_ON_PARENT)
+
+        self.statusbar = self.CreateStatusBar()
+
+        self.parent = parent
+
+        self.SetIcon(parent.GetIcon())
+
+        # Generate the dialogs
+        self.name_dialog = LabeledTextCtrl(self,
+            "Name", "Default Spool", "")
+        self.diameter_dialog = LabeledTextCtrl(self,
+            "Diameter", "1.75", "mm")
+        self.diameter_dialog.SetToolTip(
+            "Typically, either 1.75 mm or 2.85 mm (a.k.a '3')")
+        self.weight_dialog = LabeledTextCtrl(self,
+            "Weight", "1", "Kg")
+        self.density_dialog = LabeledTextCtrl(self,
+            "Density", "1.25", "g/cm^3")
+        self.density_dialog.SetToolTip(
+            "Typical densities are 1.25 g/cm^3 for PLA and 1.08 g/cm^3 for" +
+            " ABS")
+        self.length_dialog = LabeledTextCtrl(self,
+            "Length", "332601.35", "mm")
+
+        # "Program" the dialogs
+        self.diameter_dialog.Bind(wx.EVT_TEXT, self.calculateLength)
+        self.weight_dialog.Bind(wx.EVT_TEXT, self.calculateLength)
+        self.density_dialog.Bind(wx.EVT_TEXT, self.calculateLength)
+        self.length_dialog.Bind(wx.EVT_TEXT, self.calculateWeight)
+
+        # Generate the bottom buttons
+        self.add_button = wx.Button(self, wx.ID_ADD)
+        self.cancel_button = wx.Button(self, wx.ID_CANCEL)
+
+        # "Program" the bottom buttons
+        self.add_button.Bind(wx.EVT_BUTTON, self.onClickAdd)
+        self.cancel_button.Bind(wx.EVT_BUTTON, self.onClickCancel)
+
+        # Layout
+        ## Group the bottom buttons
+        self.bottom_buttons_sizer = wx.BoxSizer(wx.HORIZONTAL)
+        self.bottom_buttons_sizer.Add(self.add_button, 0, wx.FIXED_MINSIZE)
+        self.bottom_buttons_sizer.Add(self.cancel_button, 0, wx.FIXED_MINSIZE)
+
+        ## Group the whole window
+        self.full_sizer = wx.BoxSizer(wx.VERTICAL)
+        self.full_sizer.Add(self.name_dialog, 0,
+            wx.TOP | wx.BOTTOM | wx.EXPAND,  10)
+        self.full_sizer.Add(self.diameter_dialog, 0, wx.EXPAND)
+        self.full_sizer.Add(self.weight_dialog, 0, wx.EXPAND)
+        self.full_sizer.Add(self.density_dialog, 0, wx.EXPAND)
+        self.full_sizer.Add(self.length_dialog, 0, wx.EXPAND)
+        self.full_sizer.Add(self.bottom_buttons_sizer, 0,
+            wx.ALL | wx.ALIGN_CENTER_HORIZONTAL, 10)
+
+        self.SetSizerAndFit(self.full_sizer)
+
+        # Don't allow this window to be resized in height
+        add_window_size = self.GetSize()
+        self.SetMaxSize((-1, add_window_size.height))
+
+    def onClickAdd(self, event):
+        """Add the new spool and close the window."""
+
+        spool_name = self.name_dialog.field.GetValue()
+        spool_length = getFloat(self, self.length_dialog.field.GetValue())
+
+        # Check whether the length is actually a number
+        if not spool_length:
+            self.statusbar.SetStatusText(
+                "ERROR: Unrecognized length: %s." %
+                    self.length_dialog.field.GetValue())
+            return -1
+
+        # The remaining filament should always be a positive number
+        if not spool_length > 0:
+            self.statusbar.SetStatusText(
+                "ERROR: Length is zero or negative: %.2f." % spool_length)
+            return -1
+
+        # Check whether the name is already used. If it is used, prompt the
+        # user before overwriting it
+        if self.parent.spool_manager.isListed(spool_name):
+            if checkOverwrite(self, spool_name):
+                # Remove the "will be overwritten" spool
+                self.parent.spool_manager.remove(spool_name)
+            else:
+                return 0
+
+        # Add the new spool
+        self.parent.spool_manager.add(spool_name, spool_length)
+        self.parent.spool_list.refreshList(self.parent.spool_manager)
+        self.parent.current_spools_dialog.refreshDialog(
+                self.parent.spool_manager)
+        self.parent.statusbar.SetStatusText(
+            "Added new spool '%s'" % spool_name +
+            " with %.2f mm of remaining filament." % spool_length)
+
+        self.Close(True)
+
+    def onClickCancel(self, event):
+        """Do nothing and close the window."""
+        self.Close(True)
+        self.parent.statusbar.SetStatusText("")
+
+    def calculateLength(self, event):
+        """
+        Calculate the length of the filament given the mass, diameter and
+        density of the filament. Set the 'Length' field to this quantity.
+        """
+
+        mass = getFloat(self, self.weight_dialog.field.GetValue())
+        diameter = getFloat(self, self.diameter_dialog.field.GetValue())
+        density = getFloat(self, self.density_dialog.field.GetValue())
+        if mass and diameter and density:
+            pi = 3.14159265359
+            length = 4e6 * mass / pi / diameter**2 / density
+            self.length_dialog.field.ChangeValue("%.2f" % length)
+            self.statusbar.SetStatusText("")
+        else:
+            self.length_dialog.field.ChangeValue("---")
+
+    def calculateWeight(self, event):
+        """
+        Calculate the weight of the filament given the length, diameter and
+        density of the filament. Set the 'Weight' field to this value.
+        """
+
+        length = getFloat(self, self.length_dialog.field.GetValue())
+        diameter = getFloat(self, self.diameter_dialog.field.GetValue())
+        density = getFloat(self, self.density_dialog.field.GetValue())
+        if length and diameter and density:
+            pi = 3.14159265359
+            mass = length * pi * diameter**2 * density / 4e6
+            self.weight_dialog.field.ChangeValue("%.2f" % mass)
+            self.statusbar.SetStatusText("")
+        else:
+            self.weight_dialog.field.ChangeValue("---")
+
+
+class LabeledTextCtrl(wx.Panel):
+    """
+    Group together a wxTextCtrl with a preceding and a subsequent wxStaticText.
+    """
+
+    def __init__(self, parent, preceding_text, field_value, subsequent_text):
+        wx.Panel.__init__(self, parent)
+        self.pretext = wx.StaticText(self, label = preceding_text,
+            style = wx.ALIGN_RIGHT)
+        self.field = wx.TextCtrl(self, value = field_value)
+        self.subtext = wx.StaticText(self, label = subsequent_text)
+
+        # Layout the panel
+        self.sizer = wx.BoxSizer(wx.HORIZONTAL)
+        self.sizer.Add(self.pretext, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 10)
+        self.sizer.SetItemMinSize(self.pretext, (80, -1))
+        self.sizer.Add(self.field, 1, wx.EXPAND)
+        self.sizer.Add(self.subtext, 0, wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 10)
+        self.sizer.SetItemMinSize(self.subtext, (50, -1))
+
+        self.SetSizerAndFit(self.sizer)
+
+
+# ---------------------------------------------------------------------------
+class SpoolManagerEditWindow(wx.Frame):
+    """Window for editing the name or the length of a spool."""
+
+    def __init__(self, parent, spool_name, spool_length):
+
+        wx.Frame.__init__(self, parent,
+            title = "Edit Spool",
+            style = wx.DEFAULT_FRAME_STYLE | wx.FRAME_FLOAT_ON_PARENT)
+
+        self.statusbar = self.CreateStatusBar()
+
+        self.parent = parent
+
+        self.SetIcon(parent.GetIcon())
+
+        self.old_spool_name = spool_name
+        self.old_spool_length = getFloat(self, spool_length)
+
+        # Set how many millimeters will the buttons add or subtract
+        self.quantities = [-100.0, -50.0, -10.0, 10.0, 50.0, 100.0]
+
+        # Generate the name field
+        self.name_field = LabeledTextCtrl(self,
+            "Name", self.old_spool_name, "")
+
+        # Generate the length field and buttons
+        self.length_title = wx.StaticText(self, label = "Remaining filament:")
+        self.minus3_button = wx.Button(self,
+            label = str(self.quantities[0]), style = wx.BU_EXACTFIT)
+        self.minus2_button = wx.Button(self,
+            label = str(self.quantities[1]), style = wx.BU_EXACTFIT)
+        self.minus1_button = wx.Button(self,
+            label = str(self.quantities[2]), style = wx.BU_EXACTFIT)
+        self.length_field = wx.TextCtrl(self,
+            value = str(self.old_spool_length))
+        self.plus1_button = wx.Button(self,
+            label = "+" + str(self.quantities[3]), style = wx.BU_EXACTFIT)
+        self.plus2_button = wx.Button(self,
+            label = "+" + str(self.quantities[4]), style = wx.BU_EXACTFIT)
+        self.plus3_button = wx.Button(self,
+            label = "+" + str(self.quantities[5]), style = wx.BU_EXACTFIT)
+
+        # "Program" the length buttons
+        self.minus3_button.Bind(wx.EVT_BUTTON, self.changeLength)
+        self.minus2_button.Bind(wx.EVT_BUTTON, self.changeLength)
+        self.minus1_button.Bind(wx.EVT_BUTTON, self.changeLength)
+        self.plus1_button.Bind(wx.EVT_BUTTON, self.changeLength)
+        self.plus2_button.Bind(wx.EVT_BUTTON, self.changeLength)
+        self.plus3_button.Bind(wx.EVT_BUTTON, self.changeLength)
+
+        # Generate the bottom buttons
+        self.save_button = wx.Button(self, wx.ID_SAVE)
+        self.cancel_button = wx.Button(self, wx.ID_CANCEL)
+
+        # "Program" the bottom buttons
+        self.save_button.Bind(wx.EVT_BUTTON, self.onClickSave)
+        self.cancel_button.Bind(wx.EVT_BUTTON, self.onClickCancel)
+
+        # Layout
+        ## Group the length field and its correspondent buttons
+        self.length_sizer = wx.BoxSizer(wx.HORIZONTAL)
+        self.length_sizer.Add(self.minus3_button, 0,
+                              wx.FIXED_MINSIZE | wx.ALIGN_CENTER)
+        self.length_sizer.Add(self.minus2_button, 0,
+                              wx.FIXED_MINSIZE | wx.ALIGN_CENTER)
+        self.length_sizer.Add(self.minus1_button, 0,
+                              wx.FIXED_MINSIZE | wx.ALIGN_CENTER)
+        self.length_sizer.Add(self.length_field, 1, wx.EXPAND)
+        self.length_sizer.Add(self.plus1_button, 0,
+                              wx.FIXED_MINSIZE | wx.ALIGN_CENTER)
+        self.length_sizer.Add(self.plus2_button, 0,
+                              wx.FIXED_MINSIZE | wx.ALIGN_CENTER)
+        self.length_sizer.Add(self.plus3_button, 0,
+                              wx.FIXED_MINSIZE | wx.ALIGN_CENTER)
+
+        ## Group the bottom buttons
+        self.bottom_buttons_sizer = wx.BoxSizer(wx.HORIZONTAL)
+        self.bottom_buttons_sizer.Add(self.save_button, 0, wx.EXPAND)
+        self.bottom_buttons_sizer.Add(self.cancel_button, 0, wx.EXPAND)
+
+        ## Lay out the whole window
+        self.full_sizer = wx.BoxSizer(wx.VERTICAL)
+        self.full_sizer.Add(self.name_field, 0, wx.EXPAND)
+        self.full_sizer.AddSpacer(10)
+        self.full_sizer.Add(self.length_title, 0,
+            wx.LEFT | wx.RIGHT | wx.EXPAND, 10)
+        self.full_sizer.Add(self.length_sizer, 0,
+            wx.LEFT | wx.RIGHT | wx.EXPAND, 10)
+        self.full_sizer.AddSpacer(10)
+        self.full_sizer.Add(self.bottom_buttons_sizer, 0, wx.ALIGN_CENTER)
+
+        self.SetSizerAndFit(self.full_sizer)
+
+        # Don't allow this window to be resized in height
+        edit_window_size = self.GetSize()
+        self.SetMaxSize((-1, edit_window_size.height))
+
+    def changeLength(self, event):
+        new_length = getFloat(self, self.length_field.GetValue())
+        if new_length:
+            new_length = new_length + float(event.GetEventObject().GetLabel())
+            self.length_field.ChangeValue("%.2f" % new_length)
+            self.statusbar.SetStatusText("")
+
+    def onClickSave(self, event):
+
+        new_spool_name = self.name_field.field.GetValue()
+        new_spool_length = getFloat(self, self.length_field.GetValue())
+
+        # Check whether the length is actually a number
+        if not new_spool_length:
+            self.statusbar.SetStatusText(
+                "ERROR: Unrecognized length: %s." %
+                    self.length_field.GetValue())
+            return -1
+
+        if not new_spool_length > 0:
+            self.statusbar.SetStatusText(
+                "ERROR: Length is zero or negative: %.2f." % new_spool_length)
+            return -1
+
+        # Check whether the "old" spool was loaded
+        new_spool_extruder = self.parent.spool_manager.isLoaded(
+            self.old_spool_name)
+
+        # Check whether the name has changed
+        if new_spool_name == self.old_spool_name:
+            # Remove only the "old" spool
+            self.parent.spool_manager.remove(self.old_spool_name)
+        else:
+            # Check whether the new name is already used
+            if self.parent.spool_manager.isListed(new_spool_name):
+                if checkOverwrite(self, new_spool_name):
+                    # Remove the "old" and the "will be overwritten" spools
+                    self.parent.spool_manager.remove(self.old_spool_name)
+                    self.parent.spool_manager.remove(new_spool_name)
+                else:
+                    return 0
+            else:
+                # Remove only the "old" spool
+                self.parent.spool_manager.remove(self.old_spool_name)
+
+        # Add "new" or edited spool
+        self.parent.spool_manager.add(new_spool_name, new_spool_length)
+        self.parent.spool_manager.load(new_spool_name, new_spool_extruder)
+        self.parent.spool_list.refreshList(self.parent.spool_manager)
+        self.parent.current_spools_dialog.refreshDialog(
+            self.parent.spool_manager)
+        self.parent.statusbar.SetStatusText(
+            "Edited spool '%s'" % new_spool_name +
+            " with %.2f mm of remaining filament." % new_spool_length)
+
+        self.Close(True)
+
+    def onClickCancel(self, event):
+            self.Close(True)
+            self.parent.statusbar.SetStatusText("")
--- a/printrun-src/printrun/stlplater.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/stlplater.py	Wed Jan 20 10:15:13 2021 +0100
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 
 # This file is part of the Printrun suite.
 #
@@ -36,12 +36,12 @@
 from printrun import stltool
 from printrun.objectplater import make_plater, PlaterPanel
 
-glview = False
-if "-nogl" not in sys.argv:
+glview = '--no-gl' not in sys.argv
+if glview:
     try:
         from printrun import stlview
-        glview = True
     except:
+        glview = False
         logging.warning("Could not load 3D viewer for plater:"
                         + "\n" + traceback.format_exc())
 
@@ -61,7 +61,7 @@
 
 class showstl(wx.Window):
     def __init__(self, parent, size, pos):
-        wx.Window.__init__(self, parent, size = size, pos = pos)
+        super().__init__(parent, size = size, pos = pos)
         self.i = 0
         self.parent = parent
         self.previ = 0
@@ -74,7 +74,7 @@
         self.prevsel = -1
 
     def prepare_model(self, m, scale):
-        m.bitmap = wx.EmptyBitmap(800, 800, 32)
+        m.bitmap = wx.Bitmap(800, 800, 32)
         dc = wx.MemoryDC()
         dc.SelectObject(m.bitmap)
         dc.SetBackground(wx.Brush((0, 0, 0, 0)))
@@ -105,7 +105,7 @@
     def move(self, event):
         if event.ButtonUp(wx.MOUSE_BTN_LEFT):
             if self.initpos is not None:
-                currentpos = event.GetPositionTuple()
+                currentpos = event.GetPosition()
                 delta = (0.5 * (currentpos[0] - self.initpos[0]),
                          -0.5 * (currentpos[1] - self.initpos[1])
                          )
@@ -116,10 +116,10 @@
             self.parent.right(event)
         elif event.Dragging():
             if self.initpos is None:
-                self.initpos = event.GetPositionTuple()
+                self.initpos = event.GetPosition()
             self.Refresh()
             dc = wx.ClientDC(self)
-            p = event.GetPositionTuple()
+            p = event.GetPosition()
             dc.DrawLine(self.initpos[0], self.initpos[1], p[0], p[1])
             del dc
         else:
@@ -181,10 +181,7 @@
         if self.prevsel != s:
             self.i = 0
             self.prevsel = s
-        if z < 0:
-            self.rotate_shape(-1)
-        else:
-            self.rotate_shape(1)
+        self.rotate_shape(-1 if z < 0 else 1) #TEST
 
     def repaint(self, event):
         dc = wx.PaintDC(self)
@@ -195,11 +192,11 @@
             dc = wx.ClientDC(self)
         scale = 2
         dc.SetPen(wx.Pen(wx.Colour(100, 100, 100)))
-        for i in xrange(20):
+        for i in range(20):
             dc.DrawLine(0, i * scale * 10, 400, i * scale * 10)
             dc.DrawLine(i * scale * 10, 0, i * scale * 10, 400)
         dc.SetPen(wx.Pen(wx.Colour(0, 0, 0)))
-        for i in xrange(4):
+        for i in range(4):
             dc.DrawLine(0, i * scale * 50, 400, i * scale * 50)
             dc.DrawLine(i * scale * 50, 0, i * scale * 50, 400)
         dc.SetBrush(wx.Brush(wx.Colour(128, 255, 128)))
@@ -224,12 +221,12 @@
     def prepare_ui(self, filenames = [], callback = None,
                    parent = None, build_dimensions = None, circular_platform = False,
                    simarrange_path = None, antialias_samples = 0):
-        super(StlPlaterPanel, self).prepare_ui(filenames, callback, parent, build_dimensions)
+        super().prepare_ui(filenames, callback, parent, build_dimensions)
         self.cutting = False
         self.cutting_axis = None
         self.cutting_dist = None
         if glview:
-            viewer = stlview.StlViewPanel(self, (580, 580),
+            viewer = stlview.StlViewPanel(self, wx.DefaultSize,
                                           build_dimensions = self.build_dimensions,
                                           circular = circular_platform,
                                           antialias_samples = antialias_samples)
@@ -242,7 +239,7 @@
             cutconfirmbutton.Disable()
             self.cutconfirmbutton = cutconfirmbutton
             self.menusizer.Add(cutconfirmbutton, pos = (nrows, 1), span = (1, 1), flag = wx.EXPAND)
-            cutpanel = wx.Panel(self.menupanel, -1)
+            cutpanel = wx.Panel(self.menupanel)
             cutsizer = self.cutsizer = wx.BoxSizer(wx.HORIZONTAL)
             cutpanel.SetSizer(cutsizer)
             cutxplusbutton = wx.ToggleButton(cutpanel, label = _(">X"), style = wx.BU_EXACTFIT)
@@ -270,22 +267,20 @@
         self.set_viewer(viewer)
 
     def start_cutting_tool(self, event, axis, direction):
-        toggle = event.GetEventObject()
-        if toggle.GetValue():
+        toggle = event.EventObject
+        self.cutting = toggle.Value
+        if toggle.Value:
             # Disable the other toggles
-            for child in self.cutsizer.GetChildren():
-                child = child.GetWindow()
+            for child in self.cutsizer.Children:
+                child = child.Window
                 if child != toggle:
-                    child.SetValue(False)
-            self.cutting = True
+                    child.Value = False
             self.cutting_axis = axis
-            self.cutting_dist = None
             self.cutting_direction = direction
         else:
-            self.cutting = False
             self.cutting_axis = None
-            self.cutting_dist = None
             self.cutting_direction = None
+        self.cutting_dist = None
 
     def cut_confirm(self, event):
         name = self.l.GetSelection()
@@ -335,7 +330,7 @@
         best_match = None
         best_facet = None
         best_dist = float("inf")
-        for key, model in self.models.iteritems():
+        for key, model in self.models.items():
             transformation = transformation_matrix(model)
             transformed = model.transform(transformation)
             if not transformed.intersect_box(ray_near, ray_far):
@@ -478,7 +473,7 @@
         if self.simarrange_path:
             try:
                 self.autoplate_simarrange()
-            except Exception, e:
+            except Exception as e:
                 logging.warning(_("Failed to use simarrange for plating, "
                                   "falling back to the standard method. "
                                   "The error was: ") + e)
@@ -494,7 +489,7 @@
                    "-m",  # Pack around center
                    "-x", str(int(self.build_dimensions[0])),
                    "-y", str(int(self.build_dimensions[1]))] + files
-        p = subprocess.Popen(command, stdout = subprocess.PIPE)
+        p = subprocess.Popen(command, stdout = subprocess.PIPE, universal_newlines = True)
 
         pos_regexp = re.compile("File: (.*) minx: ([0-9]+), miny: ([0-9]+), minrot: ([0-9]+)")
         for line in p.stdout:
@@ -510,7 +505,7 @@
                 x = float(bits[1])
                 y = float(bits[2])
                 rot = -float(bits[3])
-                for name, model in models.items():
+                for name, model in list(models.items()):
                     # FIXME: not sure this is going to work superwell with utf8
                     if model.filename == filename:
                         model.offsets[0] = x + self.build_dimensions[3]
--- a/printrun-src/printrun/stltool.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/stltool.py	Wed Jan 20 10:15:13 2021 +0100
@@ -42,13 +42,14 @@
     return numpy.append(v, w)
 
 def applymatrix(facet, matrix = I):
-    return genfacet(map(lambda x: matrix.dot(homogeneous(x))[:3], facet[1]))
+    return genfacet([matrix.dot(homogeneous(x))[:3] for x in facet[1]])
 
-def ray_triangle_intersection(ray_near, ray_dir, (v1, v2, v3)):
+def ray_triangle_intersection(ray_near, ray_dir, v123):
     """
     Möller–Trumbore intersection algorithm in pure python
     Based on http://en.wikipedia.org/wiki/M%C3%B6ller%E2%80%93Trumbore_intersection_algorithm
     """
+    v1, v2, v3 = v123
     eps = 0.000001
     edge1 = v2 - v1
     edge2 = v3 - v1
@@ -99,7 +100,7 @@
         return
     if binary:
         with open(filename, "wb") as f:
-            buf = "".join(["\0"] * 80)
+            buf = b"".join([b"\0"] * 80)
             buf += struct.pack("<I", len(facets))
             facetformat = struct.Struct("<ffffffffffffH")
             for facet in facets:
@@ -120,7 +121,7 @@
                 f.write("  endfacet" + "\n")
             f.write("endsolid " + objname + "\n")
 
-class stl(object):
+class stl:
 
     _dims = None
 
@@ -163,7 +164,7 @@
         self.facetloc = 0
         if filename is None:
             return
-        with open(filename) as f:
+        with open(filename,encoding="ascii",errors="ignore") as f:
             data = f.read()
         if "facet normal" in data[1:300] and "outer loop" in data[1:300]:
             lines = data.split("\n")
@@ -181,7 +182,7 @@
                 buf += newdata
             facetcount = struct.unpack_from("<I", buf, 80)
             facetformat = struct.Struct("<ffffffffffffH")
-            for i in xrange(facetcount[0]):
+            for i in range(facetcount[0]):
                 buf = f.read(50)
                 while len(buf) < 50:
                     newdata = f.read(50 - len(buf))
@@ -192,8 +193,8 @@
                 self.name = "binary soloid"
                 facet = [fd[:3], [fd[3:6], fd[6:9], fd[9:12]]]
                 self.facets.append(facet)
-                self.facetsminz.append((min(map(lambda x: x[2], facet[1])), facet))
-                self.facetsmaxz.append((max(map(lambda x: x[2], facet[1])), facet))
+                self.facetsminz.append((min(x[2] for x in facet[1]), facet))
+                self.facetsmaxz.append((max(x[2] for x in facet[1]), facet))
             f.close()
             return
 
@@ -266,8 +267,8 @@
         s.facetloc = 0
         s.name = self.name
         for facet in s.facets:
-            s.facetsminz += [(min(map(lambda x:x[2], facet[1])), facet)]
-            s.facetsmaxz += [(max(map(lambda x:x[2], facet[1])), facet)]
+            s.facetsminz += [(min(x[2] for x in facet[1]), facet)]
+            s.facetsmaxz += [(max(x[2] for x in facet[1]), facet)]
         return s
 
     def translation_matrix(self, v):
@@ -328,8 +329,8 @@
         s.facetloc = 0
         s.name = self.name
         for facet in s.facets:
-            s.facetsminz += [(min(map(lambda x:x[2], facet[1])), facet)]
-            s.facetsmaxz += [(max(map(lambda x:x[2], facet[1])), facet)]
+            s.facetsminz += [(min(x[2] for x in facet[1]), facet)]
+            s.facetsmaxz += [(max(x[2] for x in facet[1]), facet)]
         return s
 
     def export(self, f = sys.stdout):
@@ -356,23 +357,23 @@
             l = l.replace(", ", ".")
             self.infacet = 1
             self.facetloc = 0
-            normal = numpy.array(map(float, l.split()[2:]))
+            normal = numpy.array([float(f) for f in l.split()[2:]])
             self.facet = (normal, (numpy.zeros(3), numpy.zeros(3), numpy.zeros(3)))
         elif l.startswith("endfacet"):
             self.infacet = 0
             self.facets.append(self.facet)
             facet = self.facet
-            self.facetsminz += [(min(map(lambda x:x[2], facet[1])), facet)]
-            self.facetsmaxz += [(max(map(lambda x:x[2], facet[1])), facet)]
+            self.facetsminz += [(min(x[2] for x in facet[1]), facet)]
+            self.facetsmaxz += [(max(x[2] for x in facet[1]), facet)]
         elif l.startswith("vertex"):
             l = l.replace(", ", ".")
-            self.facet[1][self.facetloc][:] = numpy.array(map(float, l.split()[1:]))
+            self.facet[1][self.facetloc][:] = numpy.array([float(f) for f in l.split()[1:]])
             self.facetloc += 1
         return 1
 
 if __name__ == "__main__":
     s = stl("../../Downloads/frame-vertex-neo-foot-x4.stl")
-    for i in xrange(11, 11):
+    for i in range(11, 11):
         working = s.facets[:]
         for j in reversed(sorted(s.facetsminz)):
             if j[0] > i:
@@ -385,6 +386,6 @@
             else:
                 break
 
-        print i, len(working)
+        print(i, len(working))
     emitstl("../../Downloads/frame-vertex-neo-foot-x4-a.stl", s.facets, "emitted_object")
 # stl("../prusamendel/stl/mendelplate.stl")
--- a/printrun-src/printrun/stlview.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/stlview.py	Wed Jan 20 10:15:13 2021 +0100
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 
 # This file is part of the Printrun suite.
 #
@@ -42,7 +42,7 @@
 def vec(*args):
     return (GLfloat * len(args))(*args)
 
-class stlview(object):
+class stlview:
     def __init__(self, facets, batch):
         # Create the vertex and normal arrays.
         vertices = []
@@ -54,7 +54,7 @@
                 normals.extend(i[0])
 
         # Create a list of triangle indices.
-        indices = range(3 * len(facets))  # [[3*i, 3*i+1, 3*i+2] for i in xrange(len(facets))]
+        indices = list(range(3 * len(facets)))  # [[3*i, 3*i+1, 3*i+2] for i in xrange(len(facets))]
         self.vertex_list = batch.add_indexed(len(vertices) // 3,
                                              GL_TRIANGLES,
                                              None,  # group,
@@ -69,10 +69,11 @@
 
     do_lights = False
 
-    def __init__(self, parent, size, id = wx.ID_ANY,
+    def __init__(self, parent, size,
                  build_dimensions = None, circular = False,
-                 antialias_samples = 0):
-        super(StlViewPanel, self).__init__(parent, id, wx.DefaultPosition, size, 0,
+                 antialias_samples = 0,
+                 grid = (1, 10)):
+        super().__init__(parent, wx.DefaultPosition, size, 0,
                                            antialias_samples = antialias_samples)
         self.batches = []
         self.rot = 0
@@ -87,10 +88,11 @@
         else:
             self.build_dimensions = [200, 200, 100, 0, 0, 0]
         self.platform = actors.Platform(self.build_dimensions,
-                                        circular = circular)
+                                        circular = circular,
+                                        grid = grid)
         self.dist = max(self.build_dimensions[0], self.build_dimensions[1])
         self.basequat = [0, 0, 0, 1]
-        wx.CallAfter(self.forceresize)
+        wx.CallAfter(self.forceresize) #why needed
         self.mousepos = (0, 0)
 
     def OnReshape(self):
@@ -155,8 +157,11 @@
             self.parent.clickcb(event)
 
     def forceresize(self):
-        self.SetClientSize((self.GetClientSize()[0], self.GetClientSize()[1] + 1))
-        self.SetClientSize((self.GetClientSize()[0], self.GetClientSize()[1] - 1))
+        #print('forceresize')
+        x, y = self.GetClientSize()
+        #TODO: probably not needed
+        self.SetClientSize((x, y+1))
+        self.SetClientSize((x, y))
         self.initialized = False
 
     def move(self, event):
@@ -167,27 +172,22 @@
         RMB: nothing
             with shift move viewport
         """
-        self.mousepos = event.GetPositionTuple()
-        if event.Dragging() and event.LeftIsDown():
-            self.handle_rotation(event)
-        elif event.Dragging() and event.RightIsDown():
-            self.handle_translation(event)
-        elif event.ButtonUp(wx.MOUSE_BTN_LEFT):
-            if self.initpos is not None:
-                self.initpos = None
-        elif event.ButtonUp(wx.MOUSE_BTN_RIGHT):
-            if self.initpos is not None:
-                self.initpos = None
-        else:
-            event.Skip()
-            return
+        self.mousepos = event.GetPosition()
+        if event.Dragging():
+            if event.LeftIsDown():
+                self.handle_rotation(event)
+            elif event.RightIsDown():
+                self.handle_translation(event)
+            self.Refresh(False)
+        elif event.ButtonUp(wx.MOUSE_BTN_LEFT) or \
+            event.ButtonUp(wx.MOUSE_BTN_RIGHT):
+            self.initpos = None
         event.Skip()
-        wx.CallAfter(self.Refresh)
 
     def handle_wheel(self, event):
         delta = event.GetWheelRotation()
         factor = 1.05
-        x, y = event.GetPositionTuple()
+        x, y = event.GetPosition()
         x, y, _ = self.mouse_to_3d(x, y, local_transform = True)
         if delta > 0:
             self.zoom(factor, (x, y))
@@ -257,6 +257,8 @@
         if not self.platform.initialized:
             self.platform.init()
         self.initialized = 1
+        #TODO: this probably creates constant redraw
+        # create_objects is called during OnDraw, remove
         wx.CallAfter(self.Refresh)
 
     def prepare_model(self, m, scale):
--- a/printrun-src/printrun/utils.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/utils.py	Wed Jan 20 10:15:13 2021 +0100
@@ -20,19 +20,27 @@
 import datetime
 import subprocess
 import shlex
+import locale
 import logging
 
+DATADIR = os.path.join(sys.prefix, 'share')
+
+
+def set_utf8_locale():
+    """Make sure we read/write all text files in UTF-8"""
+    lang, encoding = locale.getlocale()
+    if encoding != 'UTF-8':
+        locale.setlocale(locale.LC_CTYPE, (lang, 'UTF-8'))
+
 # Set up Internationalization using gettext
 # searching for installed locales on /usr/share; uses relative folder if not
 # found (windows)
 def install_locale(domain):
-    if os.path.exists('/usr/share/pronterface/locale'):
-        gettext.install(domain, '/usr/share/pronterface/locale', unicode = 1)
-    elif os.path.exists('/usr/local/share/pronterface/locale'):
-        gettext.install(domain, '/usr/local/share/pronterface/locale',
-                        unicode = 1)
+    shared_locale_dir = os.path.join(DATADIR, 'locale')
+    if os.path.exists(shared_locale_dir):
+        gettext.install(domain, shared_locale_dir)
     else:
-        gettext.install(domain, './locale', unicode = 1)
+        gettext.install(domain, './locale')
 
 class LogFormatter(logging.Formatter):
     def __init__(self, format_default, format_info):
@@ -71,15 +79,17 @@
         return pixmapfile(filename)
 
 def imagefile(filename):
-    for prefix in ['/usr/local/share/pronterface/images',
-                   '/usr/share/pronterface/images']:
-        candidate = os.path.join(prefix, filename)
-        if os.path.exists(candidate):
-            return candidate
+    shared_pronterface_images_dir = os.path.join(DATADIR, 'pronterface/images')
+    candidate = os.path.join(shared_pronterface_images_dir, filename)
+    if os.path.exists(candidate):
+        return candidate
     local_candidate = os.path.join(os.path.dirname(sys.argv[0]),
                                    "images", filename)
     if os.path.exists(local_candidate):
         return local_candidate
+    frozen_candidate=os.path.join(getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))),"images",filename)
+    if os.path.exists(frozen_candidate):
+        return frozen_candidate
     else:
         return os.path.join("images", filename)
 
@@ -87,6 +97,7 @@
     local_candidate = os.path.join(os.path.dirname(sys.argv[0]), filename)
     if os.path.exists(local_candidate):
         return local_candidate
+    if getattr(sys,"frozen",False): prefixes+=[getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))),]
     for prefix in prefixes:
         candidate = os.path.join(prefix, filename)
         if os.path.exists(candidate):
@@ -94,12 +105,12 @@
     return filename
 
 def pixmapfile(filename):
-    return lookup_file(filename, ['/usr/local/share/pixmaps',
-                                  '/usr/share/pixmaps'])
+    shared_pixmaps_dir = os.path.join(DATADIR, 'pixmaps')
+    return lookup_file(filename, [shared_pixmaps_dir])
 
 def sharedfile(filename):
-    return lookup_file(filename, ['/usr/local/share/pronterface',
-                                  '/usr/share/pronterface'])
+    shared_pronterface_dir = os.path.join(DATADIR, 'pronterface')
+    return lookup_file(filename, [shared_pronterface_dir])
 
 def configfile(filename):
     return lookup_file(filename, [os.path.expanduser("~/.printrun/"), ])
@@ -118,31 +129,30 @@
     return str(datetime.timedelta(seconds = int(delta)))
 
 def prepare_command(command, replaces = None):
-    command = shlex.split(command.replace("\\", "\\\\").encode())
+    command = shlex.split(command.replace("\\", "\\\\"))
     if replaces:
         replaces["$python"] = sys.executable
         for pattern, rep in replaces.items():
             command = [bit.replace(pattern, rep) for bit in command]
-    command = [bit.encode() for bit in command]
     return command
 
-def run_command(command, replaces = None, stdout = subprocess.STDOUT, stderr = subprocess.STDOUT, blocking = False):
+def run_command(command, replaces = None, stdout = subprocess.STDOUT, stderr = subprocess.STDOUT, blocking = False, universal_newlines = False):
     command = prepare_command(command, replaces)
     if blocking:
-        return subprocess.call(command)
+        return subprocess.call(command, universal_newlines = universal_newlines)
     else:
-        return subprocess.Popen(command, stderr = stderr, stdout = stdout)
+        return subprocess.Popen(command, stderr = stderr, stdout = stdout, universal_newlines = universal_newlines)
 
 def get_command_output(command, replaces):
     p = run_command(command, replaces,
                     stdout = subprocess.PIPE, stderr = subprocess.STDOUT,
-                    blocking = False)
+                    blocking = False, universal_newlines = True)
     return p.stdout.read()
 
 def dosify(name):
     return os.path.split(name)[1].split(".")[0][:8] + ".g"
 
-class RemainingTimeEstimator(object):
+class RemainingTimeEstimator:
 
     drift = None
     gcode = None
@@ -191,7 +201,7 @@
     # etc
     bdl = re.findall("([-+]?[0-9]*\.?[0-9]*)", bdim)
     defaults = [200, 200, 100, 0, 0, 0, 0, 0, 0]
-    bdl = filter(None, bdl)
+    bdl = [b for b in bdl if b]
     bdl_float = [float(value) if value else defaults[i] for i, value in enumerate(bdl)]
     if len(bdl_float) < len(defaults):
         bdl_float += [defaults[i] for i in range(len(bdl_float), len(defaults))]
@@ -205,7 +215,7 @@
 def hexcolor_to_float(color, components):
     color = color[1:]
     numel = len(color)
-    ndigits = numel / components
+    ndigits = numel // components
     div = 16 ** ndigits - 1
     return tuple(round(float(int(color[i:i + ndigits], 16)) / div, 2)
                  for i in range(0, numel, ndigits))
@@ -226,3 +236,21 @@
 def parse_temperature_report(report):
     matches = tempreport_exp.findall(report)
     return dict((m[0], (m[1], m[2])) for m in matches)
+
+def compile_file(filename):
+    with open(filename) as f:
+        return compile(f.read(), filename, 'exec')
+
+def read_history_from(filename):
+    history=[]
+    if os.path.exists(filename):
+        _hf=open(filename,encoding="utf-8")
+        for i in _hf:
+            history.append(i.rstrip())
+    return history
+
+def write_history_to(filename, hist):
+    _hf=open(filename,"w",encoding="utf-8")
+    for i in hist:
+        _hf.write(i+"\n")
+    _hf.close()
--- a/printrun-src/printrun/zscaper.py	Tue Jan 19 20:45:09 2021 +0100
+++ b/printrun-src/printrun/zscaper.py	Wed Jan 20 10:15:13 2021 +0100
@@ -14,7 +14,7 @@
 # along with Printrun.  If not, see <http://www.gnu.org/licenses/>.
 
 import wx
-from stltool import stl, genfacet, emitstl
+from .stltool import stl, genfacet, emitstl
 a = wx.App()
 
 def genscape(data = [[0, 1, 0, 0], [1, 0, 2, 0], [1, 0, 0, 0], [0, 1, 0, 1]],
@@ -25,7 +25,7 @@
     # create bottom:
     bmidpoint = (pscale * (datal - 1) / 2.0, pscale * (datah - 1) / 2.0)
     # print range(datal), bmidpoint
-    for i in zip(range(datal + 1)[:-1], range(datal + 1)[1:])[:-1]:
+    for i in list(zip(range(datal + 1)[:-1], range(datal + 1)[1:]))[:-1]:
         # print (pscale*i[0], pscale*i[1])
         o.facets += [[[0, 0, -1], [[0.0, pscale * i[0], 0.0], [0.0, pscale * i[1], 0.0], [bmidpoint[0], bmidpoint[1], 0.0]]]]
         o.facets += [[[0, 0, -1], [[2.0 * bmidpoint[1], pscale * i[1], 0.0], [2.0 * bmidpoint[1], pscale * i[0], 0.0], [bmidpoint[0], bmidpoint[1], 0.0]]]]
@@ -33,7 +33,7 @@
         o.facets += [genfacet([[2.0 * bmidpoint[1], pscale * i[1], data[i[1]][datah - 1] * zscale + bheight], [2.0 * bmidpoint[1], pscale * i[0], data[i[0]][datah - 1] * zscale + bheight], [2.0 * bmidpoint[1], pscale * i[1], 0.0]])]
         o.facets += [genfacet([[0.0, pscale * i[0], data[i[0]][0] * zscale + bheight], [0.0, pscale * i[1], 0.0], [0.0, pscale * i[0], 0.0]])]
         o.facets += [genfacet([[2.0 * bmidpoint[1], pscale * i[1], 0.0], [2.0 * bmidpoint[1], pscale * i[0], data[i[0]][datah - 1] * zscale + bheight], [2.0 * bmidpoint[1], pscale * i[0], 0.0]])]
-    for i in zip(range(datah + 1)[: - 1], range(datah + 1)[1:])[: - 1]:
+    for i in list(zip(range(datah + 1)[: - 1], range(datah + 1)[1:]))[: - 1]:
         # print (pscale * i[0], pscale * i[1])
         o.facets += [[[0, 0, -1], [[pscale * i[1], 0.0, 0.0], [pscale * i[0], 0.0, 0.0], [bmidpoint[0], bmidpoint[1], 0.0]]]]
         o.facets += [[[0, 0, -1], [[pscale * i[0], 2.0 * bmidpoint[0], 0.0], [pscale * i[1], 2.0 * bmidpoint[0], 0.0], [bmidpoint[0], bmidpoint[1], 0.0]]]]
@@ -41,8 +41,8 @@
         o.facets += [genfacet([[pscale * i[0], 2.0 * bmidpoint[0], data[datal - 1][i[0]] * zscale + bheight], [pscale * i[1], 2.0 * bmidpoint[0], data[datal - 1][i[1]] * zscale + bheight], [pscale * i[1], 2.0 * bmidpoint[0], 0.0]])]
         o.facets += [genfacet([[pscale * i[1], 0.0, 0.0], [pscale * i[0], 0.0, data[0][i[0]] * zscale + bheight], [pscale * i[0], 0.0, 0.0]])]
         o.facets += [genfacet([[pscale * i[0], 2.0 * bmidpoint[0], data[datal - 1][i[0]] * zscale + bheight], [pscale * i[1], 2.0 * bmidpoint[0], 0.0], [pscale * i[0], 2.0 * bmidpoint[0], 0.0]])]
-    for i in xrange(datah - 1):
-        for j in xrange(datal - 1):
+    for i in range(datah - 1):
+        for j in range(datal - 1):
             o.facets += [genfacet([[pscale * i, pscale * j, data[j][i] * zscale + bheight], [pscale * (i + 1), pscale * (j), data[j][i + 1] * zscale + bheight], [pscale * (i + 1), pscale * (j + 1), data[j + 1][i + 1] * zscale + bheight]])]
             o.facets += [genfacet([[pscale * (i), pscale * (j + 1), data[j + 1][i] * zscale + bheight], [pscale * i, pscale * j, data[j][i] * zscale + bheight], [pscale * (i + 1), pscale * (j + 1), data[j + 1][i + 1] * zscale + bheight]])]
             # print o.facets[-1]
@@ -50,10 +50,10 @@
 def zimage(name, out):
     i = wx.Image(name)
     s = i.GetSize()
-    print len(map(ord, i.GetData()[::3]))
-    b = map(ord, i.GetData()[::3])
+    b = list(map(ord, i.GetData()[::3]))
+    print(b)
     data = []
-    for i in xrange(s[0]):
+    for i in range(s[0]):
         data += [b[i * s[1]:(i + 1) * s[1]]]
     # data = [i[::5] for i in data[::5]]
     emitstl(out, genscape(data, zscale = 0.1).facets, name)

mercurial