Use cgroups to restrict Chromium memory, Send GCode as text not JSON string
authorJoseph Coffland <joseph@cauldrondevelopment.com>
Sun, 2 Dec 2018 08:43:23 +0000 (00:43 -0800)
committerJoseph Coffland <joseph@cauldrondevelopment.com>
Sun, 2 Dec 2018 08:43:23 +0000 (00:43 -0800)
18 files changed:
CHANGELOG.md
MANIFEST.in
scripts/browser
scripts/install.sh
scripts/rc.local [new file with mode: 0755]
src/js/api.js
src/js/control-view.js
src/js/gcode-viewer.js
src/js/orbit.js
src/js/path-viewer.js
src/js/sock.js
src/py/bbctrl/APIHandler.py
src/py/bbctrl/CommandQueue.py
src/py/bbctrl/FileHandler.py
src/py/bbctrl/Preplanner.py
src/py/bbctrl/Web.py
src/py/bbctrl/plan.py
src/stylus/style.styl

index 630943ed2835e36f4e80b6b4f950467ccdc5bea2..0549a7f707f3c9bba227e1798d137006b66a5e7b 100644 (file)
@@ -15,6 +15,8 @@ Buildbotics CNC Controller Firmware Changelog
  - Added ``Bug Report`` button to ``Admin`` -> ``General``.
  - Only render 3D view as needed to save CPU.
  - Prevent lockup due to browser causing out of memory condition.
+ - Show error message when too large GCode upload fails.
+ - Much faster 3D view loading.
 
 ## v0.4.1
  - Fix toolpath view axes bug.
index 24da8e0e26c9e5044c5262a5af26a00bae0f9a76..9dbe3dee74db4076a3300954ae2381e776f3f6b2 100644 (file)
@@ -4,5 +4,6 @@ include src/avr/bbctrl-avr-firmware.hex
 include scripts/avr109-flash.py
 include scripts/buildbotics.gc
 include scripts/xinitrc
+include scripts/rc.local
 recursive-include src/py/camotics *
 global-exclude .gitignore
index 5bb9edfa14589d0d665b60e87378cbf090205693..9dfc709ad8108eb0a0e466411b4e699b2bd50c3e 100755 (executable)
@@ -1,28 +1,16 @@
-#!/usr/bin/env python3
-
-import os
-import sys
-import resource
-import subprocess
-
-# Limit memory usage
-limit = 1.5e9
-resource.setrlimit(resource.RLIMIT_DATA, (limit, limit))
+#!/bin/bash
 
 # Clear browser errors
-prefs = '/home/pi/.config/chromium/Default/Preferences'
-subprocess.run(
-    ('sed', '-i', 's/"exited_cleanly":false/"exited_cleanly":true/', prefs))
-subprocess.run(
-    ('sed', '-i', 's/"exit_type":"Crashed"/"exit_type":"Normal"/', prefs))
+PREFS='/home/pi/.config/chromium/Default/Preferences'
+sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' $PREFS
+sed -i 's/"exit_type":"Crashed"/"exit_type":"Normal"/' $PREFS
 
 # Start browser
-cmd = '/usr/lib/chromium-browser/chromium-browser'
-args = (
-  '--no-first-run',
-  '--disable-infobars',
-  '--noerrdialogs',
-  '--single-process',
-  'http://localhost/'
-)
-os.execvp(cmd, args)
+/usr/lib/chromium-browser/chromium-browser --no-first-run \
+ --disable-infobars --noerrdialogs --disable-3d-apis --single-process \
+ http://localhost/ &
+
+# Enter cgroup
+echo $! >> /sys/fs/cgroup/memory/chrome/tasks
+
+wait
index 874835179d94ce9e65b1d0e61f31fd82797d35ed..11f64d304dd40adc58980368839795430eed1afc 100755 (executable)
@@ -43,10 +43,7 @@ fi
 #    ) >> /boot/config.txt
 #    mount -o remount,ro /boot
 #fi
-#grep "plymouth quit" /etc/rc.local
-#if [ $? -ne 0 ]; then
-#    sed -i 's/cd \/home\/pi/cd \/home\/pi; plymouth quit/' /etc/rc.local
-#fi
+#chmod ug+s /usr/lib/xorg/Xorg
 
 # Fix camera
 grep dwc_otg.fiq_fsm_mask /boot/cmdline.txt >/dev/null
@@ -57,6 +54,15 @@ if [ $? -ne 0 ]; then
     REBOOT=true
 fi
 
+# Enable memory cgroups
+grep cgroup_memory /boot/cmdline.txt >/dev/null
+if [ $? -ne 0 ]; then
+    mount -o remount,rw /boot &&
+    sed -i 's/\(.*\)/\1 cgroup_memory=1/' /boot/cmdline.txt
+    mount -o remount,ro /boot
+    REBOOT=true
+fi
+
 # Remove Hawkeye
 if [ -e /etc/init.d/hawkeye ]; then
     apt-get remove --purge -y hawkeye
@@ -96,11 +102,6 @@ if [ ! -e /etc/udev/rules.d/11-automount.rules ]; then
         echo 'LABEL="automount_end"'
     ) > /etc/udev/rules.d/11-automount.rules
 
-    grep "/etc/init.d/udev restart" /etc/rc.local >/dev/null
-    if [ $? -ne 0 ]; then
-        echo "/etc/init.d/udev restart" >> /etc/rc.local
-    fi
-
     sed -i 's/^\(MountFlags=slave\)/#\1/' /lib/systemd/system/systemd-udevd.service
     REBOOT=true
 fi
@@ -123,6 +124,10 @@ if [ ! -d /var/lib/bbctrl/upload -o -z "$(ls -A /var/lib/bbctrl/upload)" ]; then
     cp scripts/buildbotics.gc /var/lib/bbctrl/upload/
 fi
 
+# Install rc.local
+cp scripts/rc.local /etc/
+
+# Install bbctrl
 if $UPDATE_PY; then
     rm -rf /usr/local/lib/python*/dist-packages/bbctrl-*
     ./setup.py install --force
diff --git a/scripts/rc.local b/scripts/rc.local
new file mode 100755 (executable)
index 0000000..f3dc808
--- /dev/null
@@ -0,0 +1,26 @@
+#!/bin/bash
+
+# Mount /boot read only
+mount -o remount,ro /boot
+
+# Set SPI GPIO mode
+gpio mode 27 alt3
+
+# Create browser memory limited cgroup
+if [ -d sys/fs/cgroup/memory ]; then
+    CGROUP=/sys/fs/cgroup/memory/chrome
+    mkdir $CGROUP
+    chown -R pi:pi $CGROUP
+    echo 700000000 > $CGROUP/memory.limit_in_bytes
+fi
+
+# Reload udev
+/etc/init.d/udev restart
+
+# Stop boot splash so it does not interfere with X if GPU enabled
+grep ^dtoverlay=vc4-kms-v3d /boot/config.txt >/dev/null
+if [ $? -eq 0 ]; then plymouth quit; fi
+
+# Start X in /home/pi
+cd /home/pi
+sudo -u pi startx
index e8186b20a93740b2fb310d5c8d368115a484ae1f..251d5463fec65f03225005385d4b1549dedc9f35 100644 (file)
@@ -49,8 +49,10 @@ function api_cb(method, url, data, config) {
   }).error(function (xhr, status, error) {
     var text = xhr.responseText;
     try {text = $.parseJSON(xhr.responseText)} catch(e) {}
+    if (!text) text = error;
+
     d.reject(text, xhr, status, error);
-    console.debug('API Error: ' + url + ': ' + xhr.responseText);
+    console.debug('API Error: ' + url + ': ' + text);
   });
 
   return d.promise();
index d5cd60b05b8a4034adfae788b9ec8d1b20fce4a9..fab2277502cb149083e5f90020e54778f0e585bb 100644 (file)
@@ -234,6 +234,7 @@ module.exports = {
         if (this.last_file != file) return;
 
         if (typeof toolpath.progress == 'undefined') {
+          toolpath.filename = file;
           this.toolpath = toolpath;
 
           var state = this.$root.state;
@@ -294,6 +295,9 @@ module.exports = {
           this.last_file = undefined; // Force reload
           this.$broadcast('gcode-reload', file.name);
           this.update();
+
+        }.bind(this)).fail(function (error) {
+          alert('Upload failed: ' + error)
         }.bind(this));
     },
 
index bbae51a9fa49662b2b6f8706633cb2170d0df46d..16369d177db49013cb0d443dd2f970cf8b8aa0ef 100644 (file)
@@ -78,23 +78,26 @@ module.exports = {
       this.clear();
       this.file = file;
 
-      api.get('file/' + file)
-        .done(function (data) {
-          if (this.file != file) return;
+      var xhr = new XMLHttpRequest();
+      xhr.open('GET', '/api/file/' + file + '?' + Math.random(), true);
+      xhr.responseType = 'text';
 
-          var lines = data.trimRight().split(/\r?\n/);
+      xhr.onload = function (e) {
+        if (this.file != file) return;
+        var lines = xhr.response.trimRight().split(/\r?\n/);
 
-          for (var i = 0; i < lines.length; i++) {
-            lines[i] = '<li class="line-' + (i + 1) + '">' +
-              '<span class="gcode-line">' + (i + 1) + '</span>' +
-              lines[i] + '</li>';
-          }
+        for (var i = 0; i < lines.length; i++) {
+          lines[i] = '<li class="ln' + (i + 1) + '">' +
+            '<b>' + (i + 1) + '</b>' + lines[i] + '</li>';
+        }
+
+        this.clusterize.update(lines);
+        this.empty = false;
 
-          this.clusterize.update(lines);
-          this.empty = false;
+        Vue.nextTick(this.update_line);
+      }.bind(this)
 
-          Vue.nextTick(this.update_line);
-        }.bind(this));
+      xhr.send();
     },
 
 
@@ -117,7 +120,7 @@ module.exports = {
       var e = $(this.$el).find('.highlight');
       if (e.length) e.removeClass('highlight');
 
-      e = $(this.$el).find('.line-' + this.line);
+      e = $(this.$el).find('.ln' + this.line);
       if (e.length) e.addClass('highlight');
     },
 
@@ -158,3 +161,4 @@ module.exports = {
     }
   }
 }
+33554400
index 5225f9ad6008f24769c25089f96643b252504e97..45c5768b0f8d3efb6d78310af79a362da57cf60c 100644 (file)
@@ -175,12 +175,10 @@ var OrbitControls = function (object, domElement) {
         panOffset.set(0, 0, 0);
       }
 
-      scale = 1;
-
       // update condition is:
       // min(camera displacement, camera rotation in radians)^2 > EPS
       // using small-angle approximation cos(x/2) = 1 - x^2 / 8
-      if (zoomChanged ||
+      if (zoomChanged || scale != 1 ||
           lastPosition.distanceToSquared(scope.object.position) > EPS ||
           8 * (1 - lastQuaternion.dot(scope.object.quaternion)) > EPS) {
 
@@ -189,6 +187,7 @@ var OrbitControls = function (object, domElement) {
         lastPosition.copy(scope.object.position);
         lastQuaternion.copy(scope.object.quaternion);
         zoomChanged = false;
+        scale = 1;
 
         return true;
       }
index d3dac07a07b3ab2234a2cfb4b16ede8543b157b4..8e5f29d712d336ecc079c05632bf4412c0ba0aa9 100644 (file)
@@ -27,6 +27,7 @@
 
 var orbit = require('./orbit');
 var cookie = require('./cookie')('bbctrl-');
+var api = require('./api');
 
 
 function get(obj, name, defaultValue) {
@@ -60,7 +61,6 @@ module.exports = {
 
 
   computed: {
-    hasPath: function () {return typeof this.toolpath.path != 'undefined'},
     target: function () {return $(this.$el).find('.path-viewer-content')[0]}
   },
 
@@ -115,25 +115,52 @@ module.exports = {
 
   ready: function () {
     this.graphics();
-    if (typeof this.toolpath.path != 'undefined') Vue.nextTick(this.update);
+    Vue.nextTick(this.update);
   },
 
 
   methods: {
     update: function () {
-      if (!this.enabled) return;
+      if (!this.toolpath.filename && !this.loading) {
+        this.loading = true;
+        this.scene = new THREE.Scene();
+        this.dirty = true;
+      }
+
+      if (!this.enabled || !this.toolpath.filename) return;
+
+      function get(url) {
+        var d = $.Deferred();
+        var xhr = new XMLHttpRequest();
+
+        xhr.open('GET', url + '?' + Math.random(), true);
+        xhr.responseType = 'arraybuffer';
+
+        xhr.onload = function (e) {
+          if (xhr.response) d.resolve(new Float32Array(xhr.response));
+          else d.reject();
+        };
+
+        xhr.send();
 
-      // Reset message
-      this.loading = !this.hasPath;
+        return d.promise();
+      }
+
+      var d1 = get('/api/path/' + this.toolpath.filename + '/positions');
+      var d2 = get('/api/path/' + this.toolpath.filename + '/speeds');
 
-      // Update scene
-      this.scene = new THREE.Scene();
-      if (this.hasPath) {
+      $.when(d1, d2).done(function (positions, speeds) {
+        this.positions = positions
+        this.speeds = speeds;
+        this.loading = false;
+
+        // Update scene
+        this.scene = new THREE.Scene();
         this.draw(this.scene);
         this.snap(this.snapView);
-      }
 
-      this.update_view();
+        this.update_view();
+      }.bind(this))
     },
 
 
@@ -284,6 +311,7 @@ module.exports = {
           keyLight.lookAt(scope.controls.target);
           fillLight.lookAt(scope.controls.target);
           backLight.lookAt(scope.controls.target);
+          scope.dirty = true;
         }
       }(this))
 
@@ -425,47 +453,16 @@ module.exports = {
     },
 
 
-    get_color: function (rapid, speed) {
-      if (rapid) return [1, 0, 0];
+    get_color: function (speed) {
+      if (isNaN(speed)) return [255, 0, 0]; // Rapid
 
       var intensity = speed / this.toolpath.maxSpeed;
       if (typeof speed == 'undefined' || !this.showIntensity) intensity = 1;
-      return [0, intensity, 0.5 * (1 - intensity)];
+      return [0, 255 * intensity, 127 * (1 - intensity)];
     },
 
 
     draw_path: function (scene) {
-      var s = undefined;
-      var x = 0;
-      var y = 0;
-      var z = 0;
-      var color = undefined;
-
-      var positions = [];
-      var colors = [];
-
-      for (var i = 0; i < this.toolpath.path.length; i++) {
-        var step = this.toolpath.path[i];
-
-        s = get(step, 's', s);
-        var newColor = this.get_color(step.rapid, s);
-
-        // Handle color change
-        if (i && newColor != color) {
-          positions.push(x, y, z);
-          colors.push.apply(colors, newColor);
-        }
-        color = newColor;
-
-        // Draw to move target
-        x = get(step, 'x', x);
-        y = get(step, 'y', y);
-        z = get(step, 'z', z);
-
-        positions.push(x, y, z);
-        colors.push.apply(colors, color);
-      }
-
       var geometry = new THREE.BufferGeometry();
       var material =
           new THREE.LineBasicMaterial({
@@ -473,10 +470,17 @@ module.exports = {
             linewidth: 1.5
           });
 
-      geometry.addAttribute('position',
-                            new THREE.Float32BufferAttribute(positions, 3));
-      geometry.addAttribute('color',
-                            new THREE.Float32BufferAttribute(colors, 3));
+      var positions = new THREE.Float32BufferAttribute(this.positions, 3);
+      geometry.addAttribute('position', positions);
+
+      var colors = [];
+      for (var i = 0; i < this.speeds.length; i++) {
+        var color = this.get_color(this.speeds[i]);
+        Array.prototype.push.apply(colors, color);
+      }
+
+      colors = new THREE.Uint8BufferAttribute(colors, 3, true);
+      geometry.addAttribute('color', colors);
 
       geometry.computeBoundingSphere();
       geometry.computeBoundingBox();
index 6d1f7f7243126905ab81d3a6a43a81268cadda8a..5c274cd5c858441db4492157a0c245e10dee4d12 100644 (file)
@@ -32,7 +32,7 @@ var Sock = function (url, retry, timeout) {
   if (!(this instanceof Sock)) return new Sock(url, retry);
 
   if (typeof retry == 'undefined') retry = 2000;
-  if (typeof timeout == 'undefined') timeout = 8000;
+  if (typeof timeout == 'undefined') timeout = 16000;
 
   this.url = url;
   this.retry = retry;
index a5f02cbd8b30260163f7f3207842cf03c7879074..6b583596e0e5b7ed016b2fb200383fcea2b1745e 100644 (file)
@@ -45,12 +45,11 @@ class APIHandler(RequestHandler):
 
     # Override exception logging
     def log_exception(self, typ, value, tb):
-        if isinstance(value, HTTPError) and value.status_code == 408:
+        if isinstance(value, HTTPError) and value.status_code in (404, 408):
             return
 
         log.error(str(value))
         trace = ''.join(traceback.format_exception(typ, value, tb))
-        log.error(trace)
         log.debug(trace)
 
 
index 75b68cee5c00a7dc937e985b3b54dfaea3d69a9f..25bc6b7898971cac99bccca2135d5e357f5acde9 100644 (file)
@@ -78,7 +78,7 @@ class CommandQueue():
 
     def release(self, id):
         if id and not id_less(self.releaseID, id):
-            log.warning('id out of order %d <= %d' % (id, self.releaseID))
+            log.debug('id out of order %d <= %d' % (id, self.releaseID))
         self.releaseID = id
 
         self._release()
index d122d3521e97922a3b1645b905e5db7999bcc41a..a360c5c8cc4713a9256d2be4055bad133b033d42 100644 (file)
@@ -80,8 +80,10 @@ class FileHandler(bbctrl.APIHandler):
         if path:
             path = path[1:]
 
+            self.set_header('Content-Type', 'text/plain')
+
             with open('upload/' + path, 'r') as f:
-                self.write_json(f.read())
+                self.write(f.read())
 
             self.ctrl.mach.select(path)
             return
index 18b0a023b84dde3a9a9ef13aa4aac1c5e502c200..28e36ca1179379e1f137ebed54ea89af0fb2fbef 100644 (file)
@@ -50,7 +50,7 @@ def hash_dump(o):
 def plan_hash(path, config):
     path = 'upload/' + path
     h = hashlib.sha256()
-    h.update('v3'.encode('utf8'))
+    h.update('v4'.encode('utf8'))
     h.update(hash_dump(config))
 
     with open(path, 'rb') as f:
@@ -89,14 +89,14 @@ class Preplanner(object):
     def invalidate(self, filename):
         with self.lock:
             if filename in self.plans:
-                self.plans[filename][0].cancel()
+                self.plans[filename][2].set() # Cancel
                 del self.plans[filename]
 
 
     def invalidate_all(self):
         with self.lock:
             for filename, plan in self.plans.items():
-                plan[0].cancel()
+                plan[2].set() # Cancel
             self.plans = {}
 
 
@@ -128,7 +128,8 @@ class Preplanner(object):
         with self.lock:
             if filename in self.plans: plan = self.plans[filename]
             else:
-                plan = [self._plan(filename), 0]
+                cancel = threading.Event()
+                plan = [self._plan(filename, cancel), 0, cancel]
                 self.plans[filename] = plan
 
             return plan[0]
@@ -141,7 +142,7 @@ class Preplanner(object):
 
 
     @gen.coroutine
-    def _plan(self, filename):
+    def _plan(self, filename, cancel):
         # Wait until state is fully initialized
         yield self.started
 
@@ -151,7 +152,8 @@ class Preplanner(object):
         del config['default-units']
 
         # Start planner thread
-        plan = yield self.pool.submit(self._exec_plan, filename, state, config)
+        plan = yield self.pool.submit(
+            self._exec_plan, filename, state, config, cancel)
         return plan
 
 
@@ -171,19 +173,24 @@ class Preplanner(object):
 
     def _progress(self, filename, progress):
         with self.lock:
-            if not filename in self.plans: return False
-            self.plans[filename][1] = progress
-            return True
+            if filename in self.plans:
+                self.plans[filename][1] = progress
 
 
-    def _exec_plan(self, filename, state, config):
+    def _exec_plan(self, filename, state, config, cancel):
         try:
             os.nice(5)
 
             hid = plan_hash(filename, config)
-            plan_path = 'plans/' + filename + '.' + hid + '.gz'
+            base = 'plans/' + filename + '.' + hid
+            files = [
+                base + '.json', base + '.positions.gz', base + '.speeds.gz']
+
+            found = True
+            for path in files:
+                if not os.path.exists(path): found = False
 
-            if not os.path.exists(plan_path):
+            if not found:
                 self._clean_plans(filename) # Clean up old plans
 
                 path = os.path.abspath('upload/' + filename)
@@ -203,21 +210,28 @@ class Preplanner(object):
                                           cwd = tmpdir) as proc:
 
                         for line in proc.stdout:
-                            if not self._progress(filename, float(line)):
+                            self._progress(filename, float(line))
+                            if cancel.is_set():
                                 proc.terminate()
-                                return # Cancelled
+                                return
 
                         out, errs = proc.communicate()
 
-                        if not self._progress(filename, 1): return # Cancelled
+                        self._progress(filename, 1)
+                        if cancel.is_set(): return
 
                         if proc.returncode:
-                            log.error('Plan failed: ' + errs)
+                            log.error('Plan failed: ' + errs.decode('utf8'))
                             return # Failed
 
-                    os.rename(tmpdir + '/plan.json.gz', plan_path)
+                    os.rename(tmpdir + '/meta.json', files[0])
+                    os.rename(tmpdir + '/positions.gz', files[1])
+                    os.rename(tmpdir + '/speeds.gz', files[2])
+
+            with open(files[0], 'r') as f: meta = json.load(f)
+            with open(files[1], 'rb') as f: positions = f.read()
+            with open(files[2], 'rb') as f: speeds = f.read()
 
-            with open(plan_path, 'rb') as f:
-                return f.read()
+            return meta, positions, speeds
 
-        except Exception as e: log.error(e)
+        except Exception as e: log.exception(e)
index 92d664b5799c75572f25d219287c7c9c79136101..ee24ea0a93ab79021e1747e6c90cf923973b5c3d 100644 (file)
@@ -274,7 +274,7 @@ class UpgradeHandler(bbctrl.APIHandler):
 
 class PathHandler(bbctrl.APIHandler):
     @gen.coroutine
-    def get(self, filename):
+    def get(self, filename, dataType, *args):
         if not os.path.exists('upload/' + filename):
             raise HTTPError(404, 'File not found')
 
@@ -290,15 +290,27 @@ class PathHandler(bbctrl.APIHandler):
             return
 
         try:
-            if data is not None:
-                self.set_header('Content-Encoding', 'gzip')
+            if data is None: return
+            meta, positions, speeds = data
 
-                # Respond with chunks to avoid long delays
-                SIZE = 102400
-                chunks = [data[i:i + SIZE] for i in range(0, len(data), SIZE)]
-                for chunk in chunks:
-                    self.write(chunk)
-                    yield self.flush()
+            if dataType == '/positions': data = positions
+            elif dataType == '/speeds': data = speeds
+            else:
+                self.write_json(meta)
+                return
+
+            filename = filename + '-' + dataType[1:] + '.gz'
+            self.set_header('Content-Disposition', 'filename="%s"' % filename)
+            self.set_header('Content-Type', 'application/octet-stream')
+            self.set_header('Content-Encoding', 'gzip')
+            self.set_header('Content-Length', str(len(data)))
+
+            # Respond with chunks to avoid long delays
+            SIZE = 102400
+            chunks = [data[i:i + SIZE] for i in range(0, len(data), SIZE)]
+            for chunk in chunks:
+                self.write(chunk)
+                yield self.flush()
 
         except tornado.iostream.StreamClosedError as e: pass
 
@@ -456,7 +468,7 @@ class Web(tornado.web.Application):
             (r'/api/firmware/update', FirmwareUpdateHandler),
             (r'/api/upgrade', UpgradeHandler),
             (r'/api/file(/[^/]+)?', bbctrl.FileHandler),
-            (r'/api/path/([^/]+)', PathHandler),
+            (r'/api/path/([^/]+)((/positions)|(/speeds))?', PathHandler),
             (r'/api/home(/[xyzabcXYZABC]((/set)|(/clear))?)?', HomeHandler),
             (r'/api/start', StartHandler),
             (r'/api/estop', EStopHandler),
index b4c92e3b2292544b9891da5572af69c1eb0ca4d4..4734542321cb5b6de62475cfd90e69207c37a0fd 100755 (executable)
@@ -35,6 +35,8 @@ import math
 import os
 import re
 import gzip
+import struct
+import math
 import camotics.gplan as gplan # pylint: disable=no-name-in-module,import-error
 
 
@@ -46,61 +48,18 @@ reLogLine = re.compile(
     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':
+    for axis in 'xyz':
         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':
+    for axis in 'xyz':
         if axis in unit: unit[axis] /= length
 
     return unit
@@ -109,7 +68,7 @@ def compute_unit(a, b):
 def compute_move(start, unit, dist):
     move = dict()
 
-    for axis in 'xyzabc':
+    for axis in 'xyz':
         if axis in unit and axis in start:
             move[axis] = start[axis] + unit[axis] * dist
 
@@ -135,7 +94,7 @@ class Plan(object):
 
         # Initialized axis states and bounds
         self.bounds = dict(min = {}, max = {})
-        for axis in 'xyzabc':
+        for axis in 'xyz':
             self.bounds['min'][axis] = math.inf
             self.bounds['max'][axis] = -math.inf
 
@@ -153,7 +112,7 @@ class Plan(object):
 
     def get_bounds(self):
         # Remove infinity from bounds
-        for axis in 'xyzabc':
+        for axis in 'xyz':
             if self.bounds['min'][axis] == math.inf:
                 del self.bounds['min'][axis]
             if self.bounds['max'][axis] == -math.inf:
@@ -230,7 +189,7 @@ class Plan(object):
         line = 0
         maxLine = 0
         maxLineTime = time.time()
-        position = {axis: 0 for axis in 'xyzabc'}
+        position = {axis: 0 for axis in 'xyz'}
         rapid = False
 
         # Execute plan
@@ -251,7 +210,7 @@ class Plan(object):
                     move = {}
                     startPos = dict()
 
-                    for axis in 'xyzabc':
+                    for axis in 'xyz':
                         if axis in target:
                             startPos[axis] = position[axis]
                             position[axis] = target[axis]
@@ -305,23 +264,41 @@ class Plan(object):
 
 
     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))
+        lastS = 0
+        speed = 0
+        first = True
+        x, y, z = 0, 0, 0
+
+        with gzip.open('positions.gz', 'wb') as f1:
+            with gzip.open('speeds.gz', 'wb') as f2:
+                for move in self._run():
+                    x = move.get('x', x)
+                    y = move.get('y', y)
+                    z = move.get('z', z)
+                    rapid = move.get('rapid', False)
+                    speed = move.get('s', speed)
+                    s = struct.pack('<f', math.nan if rapid else speed)
+
+                    if not first and s != lastS:
+                        f1.write(p)
+                        f2.write(s)
+
+                    lastS = s
+                    first = False
+                    p = struct.pack('<fff', x, y, z)
+
+                    f1.write(p)
+                    f2.write(s)
+
+        with open('meta.json', 'w') as f:
+            meta = dict(
+                time = self.time,
+                lines = self.lines,
+                maxSpeed = self.maxSpeed,
+                bounds = self.get_bounds(),
+                messages = self.messages)
+
+            json.dump(meta, f)
 
 
 parser = argparse.ArgumentParser(description = 'Buildbotics GCode Planner')
index 8ebfc27ac0abb241ee71cc51f65c13917f2806ea..ea56da5b1c52fabf79691ecb33256632073d4654 100644 (file)
@@ -447,13 +447,17 @@ span.unit
     list-style none
 
   .gcode ul
+    li
+      line-height 15px
+
     li:nth-child(even)
       background-color #fafafa
 
     li.highlight
       background-color #eaeaea
 
-    span.gcode-line
+    li > b
+      font-weight normal
       display inline-block
       padding 0 0.25em
       color #e5aa3d