Fixed Web disconnect during simulation of large GCode. Disabled jog during pause...
authorJoseph Coffland <joseph@cauldrondevelopment.com>
Tue, 20 Nov 2018 12:32:33 +0000 (04:32 -0800)
committerJoseph Coffland <joseph@cauldrondevelopment.com>
Tue, 20 Nov 2018 12:32:33 +0000 (04:32 -0800)
CHANGELOG.md
src/js/console.js
src/js/control-view.js
src/js/path-viewer.js
src/py/bbctrl/Comm.py
src/py/bbctrl/Mach.py
src/py/bbctrl/Planner.py
src/py/bbctrl/Preplanner.py
src/py/bbctrl/State.py
src/py/bbctrl/Web.py
src/py/bbctrl/plan.py [new file with mode: 0755]

index c90fb20c6eed328bd1acaf6c77ebcae034d1e18c..f89c8d352824c15bb7c1191a0970f38cefed486d 100644 (file)
@@ -5,7 +5,7 @@ Buildbotics CNC Controller Firmware Changelog
  - Suppress ``Auto-creating missing tool`` warning.
  - Prevent ``Stream is closed`` error.
  - Suppress ``WebGL not supported`` warning.
- - Allow jogging during program or user pause.
+ - Fixed Web disconnect during simulation of large GCode.
 
 ## v0.4.1
  - Fix toolpath view axes bug.
index 52e0cd3ad2e3039f916f13f76cb7e60be968820b..5607bba55a92dd135c3ff2421b97619e22610bd5 100644 (file)
@@ -61,6 +61,7 @@ module.exports = {
       var repeat = messages.length && _msg_equal(msg, messages[0]);
       if (repeat) messages[0].repeat++;
       else {
+        msg.repeat = msg.repeat || 1;
         messages.unshift(msg);
         while (256 < messages.length) messages.pop();
       }
index 25e910cc76f3106f772114fe1f6b03e2efe9014e..8aa64b5993a3e95b38bc19c4efb52e838ed7875c 100644 (file)
@@ -217,8 +217,17 @@ module.exports = {
       api.get('path/' + file).done(function (toolpath) {
         if (this.last_file != file) return;
 
-        if (typeof toolpath.progress == 'undefined') this.toolpath = toolpath;
-        else {
+        if (typeof toolpath.progress == 'undefined') {
+          this.toolpath = toolpath;
+
+          var state = this.$root.state;
+          var bounds = toolpath.bounds;
+          for (var axis of 'xyzabc') {
+            Vue.set(state, 'path_min_' + axis, bounds.min[axis]);
+            Vue.set(state, 'path_max_' + axis, bounds.max[axis]);
+          }
+
+        } else {
           this.toolpath_progress = toolpath.progress;
           this.load_toolpath(file); // Try again
         }
index 7f07777d4c7f26f274702f134e37dc73351659a8..38eca2782c00fb4c19f891360d6230ba7025403b 100644 (file)
@@ -433,9 +433,9 @@ module.exports = {
 
     draw_path: function (scene) {
       var s = undefined;
-      var x = this.x.pos;
-      var y = this.y.pos;
-      var z = this.z.pos;
+      var x = 0;
+      var y = 0;
+      var z = 0;
       var color = undefined;
 
       var positions = [];
@@ -448,11 +448,11 @@ module.exports = {
         var newColor = this.get_color(step.rapid, s);
 
         // Handle color change
-        if (!i || newColor != color) {
-          color = newColor;
+        if (i && newColor != color) {
           positions.push(x, y, z);
-          colors.push.apply(colors, color);
+          colors.push.apply(colors, newColor);
         }
+        color = newColor;
 
         // Draw to move target
         x = get(step, 'x', x);
index 451557600c20077198cd4f93fa3ad7eb81a72c90..f8468ba15f7a81385e8cf8b756de829684443381 100644 (file)
@@ -132,7 +132,7 @@ class Comm(object):
 
         # Load next command from callback
         else:
-            cmd = self.comm_next()
+            cmd = self.comm_next() # pylint: disable=assignment-from-no-return
 
             if cmd is None: self._set_write(False) # Stop writing
             else: self._load_next_command(cmd)
index fdb07ca427a22353fa35c255cae655c4c15ab2bb..434945d275bdf7c9ba67c747e74f8df3f55eb4e5 100644 (file)
@@ -97,6 +97,8 @@ class Mach(Comm):
 
 
     def _can_jog(self):
+        return self._get_cycle() == 'idle'
+        # TODO handle jog during pause for manual tool changes
         return (self._get_cycle() == 'idle' or
                 (self._is_holding() and
                  self._get_pause_reason() in ('User pause', 'Program pause')))
@@ -107,7 +109,6 @@ class Mach(Comm):
         if current == cycle: return # No change
 
         if (current == 'idle' or (cycle == 'jogging' and self._can_jog())):
-            self.planner.update_position()
             self.ctrl.state.set('cycle', cycle)
             self.last_cycle = current
 
index fa69e1404b59c33f38a245cb4271701d22e4ace5..06df2e668d7dcd3adce9f4584186fb3e4ddddb4e 100644 (file)
@@ -62,7 +62,6 @@ class Planner():
     def is_busy(self): return self.is_running() or self.cmdq.is_active()
     def is_running(self): return self.planner.is_running()
     def is_synchronizing(self): return self.planner.is_synchronizing()
-    def set_position(self, position): self.planner.set_position(position)
 
 
     def get_position(self):
@@ -76,8 +75,10 @@ class Planner():
         return position
 
 
-    def update_position(self):
-        self.set_position(self.get_position())
+    def set_position(self, position):
+        for axis in 'xyzabc':
+            if not self.ctrl.state.is_axis_enabled(axis): continue
+            self.ctrl.state.set(axis + 'p', position[axis])
 
 
     def _get_config_vector(self, name, scale):
@@ -104,7 +105,7 @@ class Planner():
         return limit
 
 
-    def get_config(self, mdi, with_limits):
+    def get_config(self, mdi, with_limits, with_position = True):
         metric = self.ctrl.state.get('metric', True)
         config = {
             'default-units': 'METRIC' if metric else 'IMPERIAL',
@@ -115,6 +116,8 @@ class Planner():
             # TODO junction deviation & accel
             }
 
+        if with_position: config['position'] = self.get_position()
+
         if with_limits:
             minLimit = self._get_soft_limit('tn', -math.inf)
             maxLimit = self._get_soft_limit('tm', math.inf)
@@ -275,7 +278,7 @@ class Planner():
             if name == 'speed': return Cmd.speed(value)
 
             if len(name) and name[0] == '_':
-                # Don't queue axis positions, can be triggered by set_position()
+                # Don't queue axis positions, can be triggered by new position
                 if len(name) != 2 or name[1] not in 'xyzabc':
                     self._enqueue_set_cmd(id, name[1:], value)
 
@@ -352,7 +355,6 @@ class Planner():
         try:
             self.planner.stop()
             self.cmdq.clear()
-            self.update_position()
 
         except Exception as e:
             log.exception(e)
index 7b317274116331eb63e3fdb30f3d53e4c09da961..7b8d6e89c86098a7aa37768f0c86147c51aa4a18 100644 (file)
@@ -30,61 +30,18 @@ import logging
 import time
 import json
 import hashlib
-import gzip
 import glob
-import math
 import threading
+import subprocess
+import tempfile
 from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError
 from tornado import gen
-import camotics.gplan as gplan # pylint: disable=no-name-in-module,import-error
 import bbctrl
 
 
 log = logging.getLogger('Preplaner')
 
 
-# Formats floats with no more than two decimal places
-def _dump_json(o):
-    if isinstance(o, str): yield json.dumps(o)
-    elif o is None: yield 'null'
-    elif o is True: yield 'true'
-    elif o is False: yield 'false'
-    elif isinstance(o, int): yield str(o)
-
-    elif isinstance(o, float):
-        if o != o: yield '"NaN"'
-        elif o == float('inf'): yield '"Infinity"'
-        elif o == float('-inf'): yield '"-Infinity"'
-        else: yield format(o, '.2f')
-
-    elif isinstance(o, (list, tuple)):
-        yield '['
-        first = True
-
-        for item in o:
-            if first: first = False
-            else: yield ','
-            yield from _dump_json(item)
-
-        yield ']'
-
-    elif isinstance(o, dict):
-        yield '{'
-        first = True
-
-        for key, value in o.items():
-            if first: first = False
-            else: yield ','
-            yield from _dump_json(key)
-            yield ':'
-            yield from _dump_json(value)
-
-        yield '}'
-
-
-def dump_json(o): return ''.join(_dump_json(o))
-
-
 def hash_dump(o):
     s = json.dumps(o, separators = (',', ':'), sort_keys = True)
     return s.encode('utf8')
@@ -93,7 +50,7 @@ def hash_dump(o):
 def plan_hash(path, config):
     path = 'upload/' + path
     h = hashlib.sha256()
-    h.update('v2'.encode('utf8'))
+    h.update('v3'.encode('utf8'))
     h.update(hash_dump(config))
 
     with open(path, 'rb') as f:
@@ -101,47 +58,20 @@ def plan_hash(path, config):
             buf = f.read(1024 * 1024)
             if not buf: break
             h.update(buf)
-            time.sleep(0.001) # Yield some time
+            time.sleep(0.0001) # Yield some time
 
     return h.hexdigest()
 
 
-def compute_unit(a, b):
-    unit = dict()
-    length = 0
-
-    for axis in 'xyzabc':
-        if axis in a and axis in b:
-            unit[axis] = b[axis] - a[axis]
-            length += unit[axis] * unit[axis]
-
-    length = math.sqrt(length)
-
-    for axis in 'xyzabc':
-        if axis in unit: unit[axis] /= length
-
-    return unit
-
-
-def compute_move(start, unit, dist):
-    move = dict()
-
-    for axis in 'xyzabc':
-        if axis in unit and axis in start:
-            move[axis] = start[axis] + unit[axis] * dist
-
-    return move
-
 
 class Preplanner(object):
-    def __init__(self, ctrl, threads = 4, max_preplan_time = 600,
+    def __init__(self, ctrl, threads = 4, max_plan_time = 600,
                  max_loop_time = 30):
         self.ctrl = ctrl
-        self.max_preplan_time = max_preplan_time
+        self.max_plan_time = max_plan_time
         self.max_loop_time = max_loop_time
 
-        for dir in ['plans', 'meta']:
-            if not os.path.exists(dir): os.mkdir(dir)
+        if not os.path.exists('plans'): os.mkdir('plans')
 
         self.started = Future()
 
@@ -149,34 +79,6 @@ class Preplanner(object):
         self.pool = ThreadPoolExecutor(threads)
         self.lock = threading.Lock()
 
-        # Must be last
-        ctrl.state.add_listener(self._update)
-
-
-    def _update(self, update):
-        if not 'selected' in update: return
-        filename = update['selected']
-        if filename is None: return
-        future = self.get_plan(filename)
-
-        def set_bounds(type, bounds):
-            for axis in 'xyzabc':
-                if axis in bounds[type]:
-                    self.ctrl.state.set('path_%s_%s' % (type, axis),
-                                        bounds[type][axis])
-
-        def cb(future):
-            if (filename != self.ctrl.state.get('selected', '') or
-                future.cancelled()): return
-
-            path, meta = future.result()
-            bounds = meta['bounds']
-
-            set_bounds('min', bounds)
-            set_bounds('max', bounds)
-
-        self.ctrl.ioloop.add_future(future, cb)
-
 
     def start(self):
         if not self.started.done():
@@ -200,7 +102,6 @@ class Preplanner(object):
 
     def delete_all_plans(self):
         files = glob.glob('plans/*')
-        files += glob.glob('meta/*')
 
         for path in files:
             try:
@@ -212,7 +113,6 @@ class Preplanner(object):
 
     def delete_plans(self, filename):
         files = glob.glob('plans/' + filename + '.*')
-        files += glob.glob('meta/' + filename + '.*')
 
         for path in files:
             try:
@@ -247,7 +147,7 @@ class Preplanner(object):
 
         # Copy state for thread
         state = self.ctrl.state.snapshot()
-        config = self.ctrl.mach.planner.get_config(False, False)
+        config = self.ctrl.mach.planner.get_config(False, False, False)
         del config['default-units']
 
         # Start planner thread
@@ -277,183 +177,44 @@ class Preplanner(object):
 
 
     def _exec_plan(self, filename, state, config):
-        # Check if this plan was already run
         hid = plan_hash(filename, config)
         plan_path = 'plans/' + filename + '.' + hid + '.gz'
-        meta_path = 'meta/' + filename + '.' + hid + '.gz'
-
-        try:
-            if os.path.exists(plan_path) and os.path.exists(meta_path):
-                with open(plan_path, 'rb') as f: data = f.read()
-                with open(meta_path, 'rb') as f: meta = f.read()
-                meta = json.loads(gzip.decompress(meta).decode('utf8'))
-                return (data, meta)
-
-        except Exception as e: log.error(e)
-
-
-        # Clean up old plans
-        self._clean_plans(filename)
 
+        if not os.path.exists(plan_path):
+            self._clean_plans(filename) # Clean up old plans
 
-        def get_var_cb(name, units):
-            value = 0
+            path = os.path.abspath('upload/' + filename)
+            with tempfile.TemporaryDirectory() as tmpdir:
+                cmd = (
+                    '/usr/bin/env', 'python3',
+                    bbctrl.get_resource('plan.py'),
+                    path, json.dumps(state), json.dumps(config),
+                    '--max-time=%s' % self.max_plan_time,
+                    '--max-loop=%s' % self.max_loop_time
+                )
 
-            if len(name) and name[0] == '_':
-                value = state.get(name[1:], 0)
-                if units == 'IMPERIAL': value /= 25.4
+                log.info('Running: %s', cmd)
 
-            return value
+                with subprocess.Popen(cmd, stdout = subprocess.PIPE,
+                                      stderr = subprocess.PIPE,
+                                      cwd = tmpdir) as proc:
 
+                    for line in proc.stdout:
+                        if not self._progress(filename, float(line)):
+                            proc.terminate()
+                            return # Cancelled
 
-        start = time.time()
-        moves = []
-        line = 0
-        totalLines = sum(1 for line in open('upload/' + filename))
-        maxLine = 0
-        maxLineTime = time.time()
-        totalTime = 0
-        position = {}
-        maxSpeed = 0
-        currentSpeed = None
-        rapid = False
-        moves = []
-        bounds = dict(min = {}, max = {})
-        messages = []
-        count = 0
+                    out, errs = proc.communicate()
 
-        # Initialized axis states and bounds
-        for axis in 'xyzabc':
-            position[axis] = 0
-            bounds['min'][axis] = math.inf
-            bounds['max'][axis] = -math.inf
+                    if not self._progress(filename, 1): return # Cancelled
 
+                    if proc.returncode:
+                        log.error('Plan failed: ' + errs)
+                        return # Failed
 
-        def add_to_bounds(axis, value):
-            if value < bounds['min'][axis]: bounds['min'][axis] = value
-            if bounds['max'][axis] < value: bounds['max'][axis] = value
+                os.rename(tmpdir + '/plan.json.gz', plan_path)
 
-
-        def update_speed(s):
-            nonlocal currentSpeed, maxSpeed
-            if currentSpeed == s: return False
-            currentSpeed = s
-            if maxSpeed < s: maxSpeed = s
-            return True
-
-
-        # Capture planner log messages
-        levels = dict(I = 'info', D = 'debug', W = 'warning', E = 'error',
-                      C = 'critical')
-
-        def log_cb(level, msg, filename, line, column):
-            if level in levels: level = levels[level]
-
-            # Ignore missing tool warning
-            if (level == 'warning' and
-                msg.startswith('Auto-creating missing tool')):
-                return
-
-            messages.append(dict(level = level, msg = msg, filename = filename,
-                                 line = line, column = column))
-
-
-        # Initialize planner
-        self.ctrl.mach.planner.log_intercept(log_cb)
-        planner = gplan.Planner()
-        planner.set_resolver(get_var_cb)
-        planner.load('upload/' + filename, config)
-
-        # Execute plan
         try:
-            while planner.has_more():
-                cmd = planner.next()
-                planner.set_active(cmd['id']) # Release plan
-
-                # Cannot synchronize with actual machine so fake it
-                if planner.is_synchronizing(): planner.synchronize(0)
-
-                if cmd['type'] == 'line':
-                    if not (cmd.get('first', False) or
-                            cmd.get('seeking', False)):
-                        totalTime += sum(cmd['times']) / 1000
-
-                    target = cmd['target']
-                    move = {}
-                    startPos = dict()
-
-                    for axis in 'xyzabc':
-                        if axis in target:
-                            startPos[axis] = position[axis]
-                            position[axis] = target[axis]
-                            move[axis] = target[axis]
-                            add_to_bounds(axis, move[axis])
-
-                    if 'rapid' in cmd: move['rapid'] = cmd['rapid']
-
-                    if 'speeds' in cmd:
-                        unit = compute_unit(startPos, target)
-
-                        for d, s in cmd['speeds']:
-                            cur = currentSpeed
-
-                            if update_speed(s):
-                                m = compute_move(startPos, unit, d)
-                                if cur is not None:
-                                    m['s'] = cur
-                                    moves.append(m)
-                                move['s'] = s
-
-                    moves.append(move)
-
-                elif cmd['type'] == 'set':
-                    if cmd['name'] == 'line':
-                        line = cmd['value']
-                        if maxLine < line:
-                            maxLine = line
-                            maxLineTime = time.time()
-
-                    elif cmd['name'] == 'speed':
-                        s = cmd['value']
-                        if update_speed(s): moves.append({'s': s})
-
-                elif cmd['type'] == 'dwell': totalTime += cmd['seconds']
-
-                if not self._progress(filename, maxLine / totalLines):
-                    return # Plan cancelled
-
-                if self.max_preplan_time < time.time() - start:
-                    raise Exception('Max planning time (%d sec) exceeded.' %
-                                    self.max_preplan_time)
-
-                if self.max_loop_time < time.time() - maxLineTime:
-                    raise Exception('Max loop time (%d sec) exceeded.' %
-                                    self.max_loop_time)
-
-                count += 1
-                if count % 50 == 0: time.sleep(0.001) # Yield some time
-
-        except Exception as e:
-            log_cb('error', str(e), filename, line, 0)
-
-        if not self._progress(filename, 1): return # Cancelled
-
-        # Remove infinity from bounds
-        for axis in 'xyzabc':
-            if bounds['min'][axis] == math.inf: del bounds['min'][axis]
-            if bounds['max'][axis] == -math.inf: del bounds['max'][axis]
-
-        # Encode data as string
-        data = dict(time = totalTime, lines = totalLines, path = moves,
-                    maxSpeed = maxSpeed, messages = messages)
-        data = gzip.compress(dump_json(data).encode('utf8'))
-
-        # Meta data
-        meta = dict(bounds = bounds)
-        meta_comp = gzip.compress(dump_json(meta).encode('utf8'))
-
-        # Save plan & meta data
-        with open(plan_path, 'wb') as f: f.write(data)
-        with open(meta_path, 'wb') as f: f.write(meta_comp)
-
-        return (data, meta)
+            with open(plan_path, 'rb') as f:
+                return f.read()
+        except Exception as e: log.error(e)
index 648f9b0817ec9a2bd76b7ad2e9a0a2e2bca342d6..f1be5b6bd245e4f11946d93852486424557c13af 100644 (file)
@@ -35,40 +35,6 @@ import bbctrl
 log = logging.getLogger('State')
 
 
-class StateSnapshot:
-    def __init__(self, state):
-        self.vars = copy.deepcopy(state.vars)
-
-        for name in state.callbacks:
-            if not name in self.vars:
-                self.vars[name] = state.callbacks[name](name)
-
-        self.motors = {}
-        for axis in 'xyzabc':
-            self.motors[axis] = state.find_motor(axis)
-
-
-    def json(self): return dict(vars = self.vars, motors = self.motors)
-
-
-    def resolve(self, name):
-        # Resolve axis prefixes to motor numbers
-        if 2 < len(name) and name[1] == '_' and name[0] in 'xyzabc':
-            motor = self.motors[name[0]]
-            if motor is not None: return str(motor) + name[2:]
-
-        return name
-
-
-    def get(self, name, default = None):
-        name = self.resolve(name)
-
-        if name in self.vars: return self.vars[name]
-        if default is None: log.warning('State variable "%s" not found' % name)
-        return default
-
-
-
 class State(object):
     def __init__(self, ctrl):
         self.ctrl = ctrl
@@ -179,7 +145,27 @@ class State(object):
         return default
 
 
-    def snapshot(self): return StateSnapshot(self)
+    def snapshot(self):
+        vars = copy.deepcopy(self.vars)
+
+        for name in self.callbacks:
+            if not name in vars:
+                vars[name] = self.callbacks[name](name)
+
+        axis_motors = {axis: self.find_motor(axis) for axis in 'xyzabc'}
+        axis_vars = {}
+
+        for name, value in vars.items():
+            if name[0] in '0123':
+                motor = int(name[0])
+
+                for axis in 'xyzabc':
+                    if motor == axis_motors[axis]:
+                        axis_vars[axis + '_' + name[1:]] = value
+
+        vars.update(axis_vars)
+
+        return vars
 
 
     def config(self, code, value):
index 1062b9c00097cd88ae95f6d50423f444d2a37ce5..0e2f0f162dbb4539927aaa0fa8e4a8180e379de0 100644 (file)
@@ -251,7 +251,6 @@ class PathHandler(bbctrl.APIHandler):
 
         try:
             if data is not None:
-                data = data[0] # We only want the compressed path
                 self.set_header('Content-Encoding', 'gzip')
 
                 # Respond with chunks to avoid long delays
diff --git a/src/py/bbctrl/plan.py b/src/py/bbctrl/plan.py
new file mode 100755 (executable)
index 0000000..dffaf82
--- /dev/null
@@ -0,0 +1,343 @@
+#!/usr/bin/env python3
+
+################################################################################
+#                                                                              #
+#                This file is part of the Buildbotics firmware.                #
+#                                                                              #
+#                  Copyright (c) 2015 - 2018, Buildbotics LLC                  #
+#                             All rights reserved.                             #
+#                                                                              #
+#     This file ("the software") is free software: you can redistribute it     #
+#     and/or modify it under the terms of the GNU General Public License,      #
+#      version 2 as published by the Free Software Foundation. You should      #
+#      have received a copy of the GNU General Public License, version 2       #
+#     along with the software. If not, see <http://www.gnu.org/licenses/>.     #
+#                                                                              #
+#     The software 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       #
+#               Lesser General Public License for more details.                #
+#                                                                              #
+#       You should have received a copy of the GNU Lesser General Public       #
+#                License along with the software.  If not, see                 #
+#                       <http://www.gnu.org/licenses/>.                        #
+#                                                                              #
+#                For information regarding this software email:                #
+#                  "Joseph Coffland" <joseph@buildbotics.com>                  #
+#                                                                              #
+################################################################################
+
+import sys
+import argparse
+import json
+import time
+import math
+import os
+import re
+import gzip
+import camotics.gplan as gplan # pylint: disable=no-name-in-module,import-error
+
+
+reLogLine = re.compile(
+    r'^(?P<level>[A-Z])[0-9 ]:'
+    r'((?P<file>[^:]+):)?'
+    r'((?P<line>\d+):)?'
+    r'((?P<column>\d+):)?'
+    r'(?P<msg>.*)$')
+
+
+
+# Formats floats with no more than two decimal places
+def _dump_json(o):
+    if isinstance(o, str): yield json.dumps(o)
+    elif o is None: yield 'null'
+    elif o is True: yield 'true'
+    elif o is False: yield 'false'
+    elif isinstance(o, int): yield str(o)
+
+    elif isinstance(o, float):
+        if o != o: yield '"NaN"'
+        elif o == float('inf'): yield '"Infinity"'
+        elif o == float('-inf'): yield '"-Infinity"'
+        else: yield format(o, '.2f')
+
+    elif isinstance(o, (list, tuple)):
+        yield '['
+        first = True
+
+        for item in o:
+            if first: first = False
+            else: yield ','
+            yield from _dump_json(item)
+
+        yield ']'
+
+    elif isinstance(o, dict):
+        yield '{'
+        first = True
+
+        for key, value in o.items():
+            if first: first = False
+            else: yield ','
+            yield from _dump_json(key)
+            yield ':'
+            yield from _dump_json(value)
+
+        yield '}'
+
+
+def dump_json(o): return ''.join(_dump_json(o))
+
+
+def compute_unit(a, b):
+    unit = dict()
+    length = 0
+
+    for axis in 'xyzabc':
+        if axis in a and axis in b:
+            unit[axis] = b[axis] - a[axis]
+            length += unit[axis] * unit[axis]
+
+    length = math.sqrt(length)
+
+    for axis in 'xyzabc':
+        if axis in unit: unit[axis] /= length
+
+    return unit
+
+
+def compute_move(start, unit, dist):
+    move = dict()
+
+    for axis in 'xyzabc':
+        if axis in unit and axis in start:
+            move[axis] = start[axis] + unit[axis] * dist
+
+    return move
+
+
+class Plan(object):
+    def __init__(self, path, state, config):
+        self.path = path
+        self.state = state
+        self.config = config
+
+        self.lines = sum(1 for line in open(path))
+
+        self.planner = gplan.Planner()
+        self.planner.set_resolver(self.get_var_cb)
+        self.planner.set_logger(self._log_cb, 1, 'LinePlanner:3')
+        self.planner.load(self.path, config)
+
+        self.messages = []
+        self.levels = dict(I = 'info', D = 'debug', W = 'warning', E = 'error',
+                           C = 'critical')
+
+        # Initialized axis states and bounds
+        self.bounds = dict(min = {}, max = {})
+        for axis in 'xyzabc':
+            self.bounds['min'][axis] = math.inf
+            self.bounds['max'][axis] = -math.inf
+
+        self.maxSpeed = 0
+        self.currentSpeed = None
+        self.lastProgress = None
+        self.lastProgressTime = 0
+        self.time = 0
+
+
+    def add_to_bounds(self, axis, value):
+        if value < self.bounds['min'][axis]: self.bounds['min'][axis] = value
+        if self.bounds['max'][axis] < value: self.bounds['max'][axis] = value
+
+
+    def get_bounds(self):
+        # Remove infinity from bounds
+        for axis in 'xyzabc':
+            if self.bounds['min'][axis] == math.inf:
+                del self.bounds['min'][axis]
+            if self.bounds['max'][axis] == -math.inf:
+                del self.bounds['max'][axis]
+
+        return self.bounds
+
+
+    def update_speed(self, s):
+        if self.currentSpeed == s: return False
+        self.currentSpeed = s
+        if self.maxSpeed < s: self.maxSpeed = s
+
+        return True
+
+
+    def get_var_cb(self, name, units):
+        value = 0
+
+        if len(name) and name[0] == '_':
+            value = self.state.get(name[1:], 0)
+            if units == 'IMPERIAL': value /= 25.4
+
+        return value
+
+
+    def log_cb(self, level, msg, filename, line, column):
+        if level in self.levels: level = self.levels[level]
+
+        # Ignore missing tool warning
+        if (level == 'warning' and
+            msg.startswith('Auto-creating missing tool')):
+            return
+
+        self.messages.append(
+            dict(level = level, msg = msg, filename = filename, line = line,
+                 column = column))
+
+
+    def _log_cb(self, line):
+        line = line.strip()
+        m = reLogLine.match(line)
+        if not m: return
+
+        level = m.group('level')
+        msg = m.group('msg')
+        filename = m.group('file')
+        line = m.group('line')
+        column = m.group('column')
+
+        where = ':'.join(filter(None.__ne__, [filename, line, column]))
+
+        if line is not None: line = int(line)
+        if column is not None: column = int(column)
+
+        self.log_cb(level, msg, filename, line, column)
+
+
+    def progress(self, x):
+        if time.time() - self.lastProgressTime < 1 and x != 1: return
+        self.lastProgressTime = time.time()
+
+        p = '%.4f\n' % x
+
+        if self.lastProgress == p: return
+        self.lastProgress = p
+
+        sys.stdout.write(p)
+        sys.stdout.flush()
+
+
+    def _run(self):
+        start = time.time()
+        line = 0
+        maxLine = 0
+        maxLineTime = time.time()
+        position = {axis: 0 for axis in 'xyzabc'}
+        rapid = False
+
+        # Execute plan
+        try:
+            while self.planner.has_more():
+                cmd = self.planner.next()
+                self.planner.set_active(cmd['id']) # Release plan
+
+                # Cannot synchronize with actual machine so fake it
+                if self.planner.is_synchronizing(): self.planner.synchronize(0)
+
+                if cmd['type'] == 'line':
+                    if not (cmd.get('first', False) or
+                            cmd.get('seeking', False)):
+                        self.time += sum(cmd['times']) / 1000
+
+                    target = cmd['target']
+                    move = {}
+                    startPos = dict()
+
+                    for axis in 'xyzabc':
+                        if axis in target:
+                            startPos[axis] = position[axis]
+                            position[axis] = target[axis]
+                            move[axis] = target[axis]
+                            self.add_to_bounds(axis, move[axis])
+
+                    if 'rapid' in cmd: move['rapid'] = cmd['rapid']
+
+                    if 'speeds' in cmd:
+                        unit = compute_unit(startPos, target)
+
+                        for d, s in cmd['speeds']:
+                            cur = self.currentSpeed
+
+                            if self.update_speed(s):
+                                m = compute_move(startPos, unit, d)
+
+                                if cur is not None:
+                                    m['s'] = cur
+                                    yield m
+
+                                move['s'] = s
+
+                    yield move
+
+                elif cmd['type'] == 'set':
+                    if cmd['name'] == 'line':
+                        line = cmd['value']
+                        if maxLine < line:
+                            maxLine = line
+                            maxLineTime = time.time()
+
+                    elif cmd['name'] == 'speed':
+                        s = cmd['value']
+                        if self.update_speed(s): yield {'s': s}
+
+                elif cmd['type'] == 'dwell': self.time += cmd['seconds']
+
+                if args.max_time < time.time() - start:
+                    raise Exception('Max planning time (%d sec) exceeded.' %
+                                    args.max_time)
+
+                if args.max_loop < time.time() - maxLineTime:
+                    raise Exception('Max loop time (%d sec) exceeded.' %
+                                    args.max_loop)
+
+                self.progress(maxLine / self.lines)
+
+        except Exception as e:
+            self.log_cb('error', str(e), os.path.basename(self.path), line, 0)
+
+
+    def run(self):
+        with gzip.open('plan.json.gz', 'wb') as f:
+            def write(s): f.write(s.encode('utf8'))
+
+            write('{"path":[')
+
+            first = True
+            for move in self._run():
+                if first: first = False
+                else: write(',')
+                write(dump_json(move))
+
+            write('],')
+            write('"time":%.2f,' % self.time)
+            write('"lines":%s,' % self.lines)
+            write('"maxSpeed":%s,' % self.maxSpeed)
+            write('"bounds":%s,' % dump_json(self.get_bounds()))
+            write('"messages":%s}' % dump_json(self.messages))
+
+
+parser = argparse.ArgumentParser(description = 'Buildbotics GCode Planner')
+parser.add_argument('gcode', help = 'The GCode file to plan')
+parser.add_argument('state', help = 'GCode state variables')
+parser.add_argument('config', help = 'Planner config')
+
+parser.add_argument('--max-time', default = 600,
+                    type = int, help = 'Maximum planning time in seconds')
+parser.add_argument('--max-loop', default = 30,
+                    type = int, help = 'Maximum time in loop in seconds')
+
+args = parser.parse_args()
+
+state = json.loads(args.state)
+config = json.loads(args.config)
+
+plan = Plan(args.gcode, state, config)
+plan.run()