diff --git a/modules/index.js b/modules/index.js
index cd0ffdc3ef792ff35895ca909b16e69e3017dc89..cdc41472f56741051ef0af68caf4ec21b663a326 100644
--- a/modules/index.js
+++ b/modules/index.js
@@ -1,3 +1,5 @@
+module_menu('   hpgl','modules/read/hpgl.js')
+module_menu('          PPA','modules/path/machines/CBA/PPA.js')
 module_label('character')
 module_menu('   convert','modules/character/convert')
 module_menu('   in out','modules/character/in%20out')
@@ -127,6 +129,8 @@ module_menu('      dxf','modules/path/formats/dxf')
 module_menu('      g-code','modules/path/formats/g-code')
 module_menu('      svg','modules/path/formats/svg')
 module_label('   machines')
+module_label('      CBA')
+module_menu('          PPA','modules/path/machines/CBA/PPA.js')
 module_label('      Roland')
 module_label('         milling')
 module_menu('            MDX-20','modules/path/machines/Roland/milling/MDX-20')
@@ -151,6 +155,7 @@ module_menu('   png','modules/read/png')
 module_menu('   stl','modules/read/stl')
 module_menu('   svg','modules/read/svg')
 module_menu('   text','modules/read/text')
+module_menu('   hpgl','modules/read/hpgl.js')
 module_label('socket')
 module_label('   server')
 module_menu('      device','modules/socket/server/device')
@@ -170,4 +175,3 @@ module_menu('   color','modules/ui/color')
 module_menu('   label','modules/ui/label')
 module_menu('   slider','modules/ui/slider')
 module_menu('   text window','modules/ui/text%20window')
-
diff --git a/modules/path/machines/CBA/PPA.js b/modules/path/machines/CBA/PPA.js
new file mode 100644
index 0000000000000000000000000000000000000000..afe47047bdfffa15fd8ec4da02209744251a7f6c
--- /dev/null
+++ b/modules/path/machines/CBA/PPA.js
@@ -0,0 +1,317 @@
+//
+// Parallel Prismatic Actuator (PPA) is a 3 axis motion stage for on site setup.
+//
+// David Preiss / Eyal Perry
+// (c) Massachusetts Institute of Technology 2021
+//
+// This work may be reproduced, modified, distributed, performed, and
+// displayed for any purpose, but must acknowledge the mods
+// project. Copyright is retained and must be preserved. The work is
+// provided as is; no warranty is provided, and users accept all
+// liability.
+//
+// closure
+//
+(function(){
+//
+// module globals
+//
+var mod = {}
+//
+// name
+//
+var name = 'PPA'
+//
+// initialization
+//
+var init = function() {
+   mod.baseLength.value = 39.5*25.4;
+   mod.maxHypo.value = 1208.2;
+   mod.yMargin.value = 5;
+   mod.chunkLength.value = 0.1;
+   draw_boundary();
+   }
+//
+// inputs
+//
+var inputs = {
+   toolpath:{type:'',
+      event:function(evt){
+        console.log(evt.detail);
+        mod.path = evt.detail.path;
+        mod.maxX = evt.detail.maxX;
+        mod.maxY = evt.detail.maxY;
+        make_path()
+      }}}
+//
+// outputs
+//
+var outputs = {
+   file:{type:'',
+      event:function(str){
+         obj = {}
+         obj.type = 'file'
+         obj.name = mod.name+'.camm'
+         obj.contents = str
+         mods.output(mod,'file',obj)
+         }}}
+//
+// interface
+//
+var interface = function(div){
+   mod.div = div
+   //
+   // on-screen drawing canvas
+   //
+   var canvas = document.createElement('canvas')
+      canvas.width = mods.ui.canvas
+      canvas.height = mods.ui.canvas
+      canvas.style.backgroundColor = 'rgb(255,255,255)'
+      div.appendChild(canvas)
+      mod.canvas = canvas
+   div.appendChild(document.createElement('br'))
+
+   div.appendChild(document.createTextNode('base length (mm): '))
+   var input = document.createElement('input')
+      input.type = 'text'
+      input.size = 6
+      input.addEventListener('change', (event) => {
+        draw_boundary();
+      });
+      div.appendChild(input)
+      mod.baseLength = input
+   div.appendChild(document.createElement('br'))
+   div.appendChild(document.createTextNode('max hypotenuse (mm): '))
+   var input = document.createElement('input')
+      input.type = 'text'
+      input.size = 6
+      input.addEventListener('change', (event) => {
+        draw_boundary();
+      });
+      div.appendChild(input)
+      mod.maxHypo = input
+   div.appendChild(document.createElement('br'))
+   div.appendChild(document.createTextNode('y margin (%): '))
+   var input = document.createElement('input')
+      input.type = 'text'
+      input.size = 6
+      input.addEventListener('change', (event) => {
+        draw_boundary();
+      });
+      div.appendChild(input)
+      mod.yMargin = input
+   div.appendChild(document.createElement('br'))
+   div.appendChild(document.createTextNode('chunk length (mm): '))
+   var input = document.createElement('input')
+      input.type = 'text'
+      input.size = 6
+      div.appendChild(input)
+      mod.chunkLength = input
+   div.appendChild(document.createElement('br'))
+
+
+}
+//
+// local functions
+//
+function draw_boundary() {
+  var w = mod.canvas.width;
+  var h = mod.canvas.height;
+
+  var bl = parseFloat(mod.baseLength.value);
+  var mh = parseFloat(mod.maxHypo.value);
+  var margin = parseFloat(mod.yMargin.value) / 100;
+  var triH = Math.sqrt(Math.pow(mh, 2) - Math.pow(bl /2, 2));
+  var maxW = bl;
+  if (bl < mh) {
+    maxW = mh + 2 * (mh - bl);
+  }
+  var centerW = maxW / 2;
+  console.log("bl:", bl);
+  console.log("mh:", mh);
+  console.log("triH:", triH);
+  console.log("maxW:", maxW);
+
+  var scaleX = w / maxW;
+  var scaleY = h / triH;
+  mod.scale = scaleX > scaleY ? scaleY : scaleX;
+  mod.drawOffsetX = (centerW - (bl/2))*mod.scale;
+
+  var angle = Math.asin(triH / mh);
+  console.log("angle", angle * 180 / Math.PI);
+
+  var ctx = mod.canvas.getContext('2d');
+  ctx.clearRect(0,0,mod.canvas.width,mod.canvas.height)
+
+  ctx.beginPath();
+  ctx.setLineDash([5, 10]);
+  ctx.arc((centerW - (bl/2))*mod.scale, 0, mh * mod.scale, 0, angle);
+  ctx.strokeStyle = "green";
+  ctx.stroke();
+
+  ctx.beginPath();
+  ctx.setLineDash([5, 10]);
+  ctx.arc((centerW + (bl/2))*mod.scale, 0, mh * mod.scale, Math.PI - angle, Math.PI);
+  ctx.strokeStyle = "green";
+  ctx.stroke();
+
+  ctx.beginPath();
+  ctx.setLineDash([5, 10]);
+  ctx.moveTo(0, triH * margin * mod.scale);
+  ctx.lineTo(w, triH * margin * mod.scale);
+  ctx.strokeStyle = "green";
+  ctx.stroke();
+}
+
+function flip_path() {
+  for (var i = 0; i < mod.path.length; i++) {
+    var tmp = mod.path[i][0];
+    mod.path[i][0] = mod.path[i][1];
+    mod.path[i][1] = tmp;
+  }
+  var tmp = mod.maxY;
+  mod.maxY = mod.maxX;
+  mod.maxX = tmp;
+}
+
+function scale_path() {
+  var bl = parseFloat(mod.baseLength.value);
+  // machine scale
+  var machineScale = bl / mod.maxX;
+
+  for (var i = 0; i < mod.path.length; i++) {
+    mod.path[i][0] *= machineScale;
+    mod.path[i][1] *= machineScale;
+  }
+
+  mod.maxX *= machineScale;
+  mod.maxY *= machineScale;
+}
+
+function center_path() {
+  var bl = parseFloat(mod.baseLength.value);
+  var mh = parseFloat(mod.maxHypo.value);
+  var margin = parseFloat(mod.yMargin.value) / 100;
+  var triH = Math.sqrt(Math.pow(mh, 2) - Math.pow(bl /2, 2));
+
+  var offsetX = (bl / 2) - (mod.maxX / 2);
+  var offsetY = mh * margin;
+
+  console.log("offsetX", offsetX);
+  console.log("offsetY", offsetY);
+
+  for (var i = 0; i < mod.path.length; i++) {
+    mod.path[i][0] += offsetX;
+    mod.path[i][1] += offsetY;
+  }
+
+  mod.maxX += offsetX;
+  mod.maxY += offsetY;
+}
+
+function make_path() {
+  draw_boundary();
+
+  if (!mod.path) return;
+
+  if (mod.maxY > mod.maxX) {
+    flip_path();
+  }
+
+  scale_path();
+
+  center_path();
+
+
+
+  // draw
+  var ctx = mod.canvas.getContext('2d');
+
+  var path = mod.path;
+
+  ctx.beginPath();
+  ctx.setLineDash([])
+  ctx.moveTo(path[0][0] * mod.scale + mod.drawOffsetX, path[0][1] * mod.scale);
+  for (var i = 1; i < path.length; i++) {
+    if (path[i][2] == 0) {
+      ctx.lineTo(path[i][0] * mod.scale + mod.drawOffsetX, path[i][1] * mod.scale);
+    } else {
+      ctx.moveTo(path[i][0] * mod.scale + mod.drawOffsetX, path[i][1] * mod.scale);
+    }
+  }
+  ctx.strokeStyle = "red";
+  ctx.stroke();
+
+
+  // construct path
+   var str = "";
+   var bl = parseFloat(mod.baseLength.value);
+   var cl = parseFloat(mod.chunkLength.value);
+   var mh = parseFloat(mod.maxHypo.value);
+
+   // init
+   var l1 = mh;
+   var l2 = mh;
+   var z = 1;
+
+   str += "L1,"+l1.toFixed(4).toString()+",L2,"+l2.toFixed(4).toString()+",Z,"+z.toString()+"\n";
+
+   var x = bl / 2;
+   var y = Math.sqrt(Math.pow(mh, 2) - Math.pow(x, 2));
+
+   console.log("Start x,y", x, y);
+
+   ctx.beginPath();
+   ctx.setLineDash([2, 3]);
+   ctx.moveTo(x * mod.scale + mod.drawOffsetX, y * mod.scale);
+   for (var i = 0; i < mod.path.length; i++) {
+     var new_x = mod.path[i][0];
+     var new_y = mod.path[i][1];
+     var new_z = mod.path[i][2];
+
+     var d = Math.sqrt(Math.pow(new_x - x, 2) + Math.pow(new_y - y, 2));
+
+     var dir_x = new_x - x;
+     var dir_y = new_y - y;
+     var dir_norm = Math.sqrt(Math.pow(dir_x, 2) + Math.pow(dir_y, 2));
+     if (dir_norm > 0) {
+       dir_x /= dir_norm;
+       dir_y /= dir_norm;
+       while (d > cl) {
+         x += dir_x * cl * Math.sqrt(2) / 2;
+         y += dir_y * cl * Math.sqrt(2) / 2;
+         d = Math.sqrt(Math.pow(new_x - x, 2) + Math.pow(new_y - y, 2));
+
+         l1 = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
+         l2 = Math.sqrt(Math.pow(bl - x, 2) + Math.pow(y, 2));
+         str += "L1,"+l1.toFixed(4).toString()+",L2,"+l2.toFixed(4).toString()+",Z,"+z.toString()+"\n";
+
+         ctx.lineTo(x * mod.scale + mod.drawOffsetX, y * mod.scale);
+       }
+     }
+
+     x = new_x;
+     y = new_y;
+     z = new_z;
+     l1 = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
+     l2 = Math.sqrt(Math.pow(bl - x, 2) + Math.pow(y, 2));
+     str += "L1,"+l1.toFixed(4).toString()+",L2,"+l2.toFixed(4).toString()+",Z,"+z.toString()+"\n";
+     ctx.lineTo(x * mod.scale + mod.drawOffsetX, y * mod.scale);
+   }
+   console.log(str);
+   ctx.strokeStyle = "blue";
+   ctx.stroke();
+   //outputs.file.event(str)
+}
+
+//
+// return values
+//
+return ({
+   name:name,
+   init:init,
+   inputs:inputs,
+   outputs:outputs,
+   interface:interface
+   })
+}())
diff --git a/modules/read/hpgl.js b/modules/read/hpgl.js
new file mode 100644
index 0000000000000000000000000000000000000000..72544c780c7ff6b64b0b3b41805b613a14e97262
--- /dev/null
+++ b/modules/read/hpgl.js
@@ -0,0 +1,248 @@
+//
+// read SVG
+//
+// Eyal Perry
+// (c) Massachusetts Institute of Technology 2021
+//
+// This work may be reproduced, modified, distributed, performed, and
+// displayed for any purpose, but must acknowledge the mods
+// project. Copyright is retained and must be preserved. The work is
+// provided as is; no warranty is provided, and users accept all
+// liability.
+//
+// closure
+//
+(function(){
+//
+// module globals
+//
+var mod = {}
+//
+// name
+//
+var name = 'read HPGL'
+//
+// initialization
+//
+var init = function() {
+   }
+//
+// inputs
+//
+var inputs = {
+   HPGL:{type:'string',
+      event:function(evt) {
+         hpgl_load_handler({target:{result:evt.detail}})
+       }}
+}
+//
+// outputs
+//
+var outputs = {
+   toolpath:{type:'array',
+      event:function(){
+        cmd = {}
+        cmd.path = mod.path;
+        cmd.maxX = mod.maxX;
+        cmd.maxY = mod.maxY;
+        mods.output(mod,'toolpath',cmd)
+    }}
+}
+//
+// interface
+//
+var interface = function(div){
+   mod.div = div
+   //
+   // file input control
+   //
+   var file = document.createElement('input')
+      file.setAttribute('type','file')
+      file.setAttribute('id',div.id+'file_input')
+      file.style.position = 'absolute'
+      file.style.left = 0
+      file.style.top = 0
+      file.style.width = 0
+      file.style.height = 0
+      file.style.opacity = 0
+      file.addEventListener('change',function() {
+         hpgl_read_handler()
+         })
+      div.appendChild(file)
+      mod.file = file
+   //
+   // on-screen drawing canvas
+   //
+   var canvas = document.createElement('canvas')
+      canvas.width = mods.ui.canvas
+      canvas.height = mods.ui.canvas
+      canvas.style.backgroundColor = 'rgb(255,255,255)'
+      div.appendChild(canvas)
+      mod.canvas = canvas
+   div.appendChild(document.createElement('br'))
+   //
+   // off-screen image canvas
+   //
+   var canvas = document.createElement('canvas')
+      mod.img = canvas
+   //
+   // file select button
+   //
+   var btn = document.createElement('button')
+      btn.style.padding = mods.ui.padding
+      btn.style.margin = 1
+      btn.appendChild(document.createTextNode('select HPGL file'))
+      btn.addEventListener('click',function(){
+         var file = document.getElementById(div.id+'file_input')
+         file.value = null
+         file.click()
+         })
+      div.appendChild(btn)
+   div.appendChild(document.createElement('br'))
+   //
+   // view button
+   //
+   var btn = document.createElement('button')
+      btn.style.padding = mods.ui.padding
+      btn.style.margin = 1
+      btn.appendChild(document.createTextNode('view'))
+      btn.addEventListener('click',function(){
+         var win = window.open('')
+         var btn = document.createElement('button')
+            btn.appendChild(document.createTextNode('close'))
+            btn.style.padding = mods.ui.padding
+            btn.style.margin = 1
+            btn.addEventListener('click',function(){
+               win.close()
+               })
+            win.document.body.appendChild(btn)
+         win.document.body.appendChild(document.createElement('br'))
+         var canvas = document.createElement('canvas')
+            canvas.width = mod.img.width
+            canvas.height = mod.img.height
+            win.document.body.appendChild(canvas)
+         var ctx = canvas.getContext("2d")
+            ctx.drawImage(mod.img,0,0)
+         })
+      div.appendChild(btn)
+   div.appendChild(document.createElement('br'))
+   //
+   // info div
+   //
+   var info = document.createElement('div')
+      info.setAttribute('id',div.id+'info')
+      var text = document.createTextNode('file:')
+         info.appendChild(text)
+         mod.name = text
+      info.appendChild(document.createElement('br'))
+      var text = document.createTextNode('width:')
+         info.appendChild(text)
+         mod.width = text
+      info.appendChild(document.createElement('br'))
+      var text = document.createTextNode('height:')
+         info.appendChild(text)
+         mod.height = text
+      div.appendChild(info)
+   }
+//
+// local functions
+//
+// read handler
+//
+function hpgl_read_handler(event) {
+   //
+   // read as text
+   //
+   var file_reader = new FileReader()
+   file_reader.onload = hpgl_load_handler
+   var input_file = mod.file.files[0]
+   var file_name = input_file.name
+   mod.name.nodeValue = "file: "+file_name
+   file_reader.readAsText(input_file)
+   }
+//
+// load handler
+//
+function hpgl_load_handler(event) {
+   var cmds = event.target.result.split(";");
+   var path = [];
+   for (var i = 0; i < cmds.length; i++) {
+     var cmd = cmds[i];
+     if (cmd.startsWith("IN")) {
+       // initialize, start a plotting job
+     } else if (cmd.startsWith("SP")) {
+       // select pen
+       console.log("Select pen:", cmd.substring(2));
+     } else if (cmd.startsWith("PU")) {
+       // pen up
+       console.log("Pen up", cmd.substring(2));
+       var xy = cmd.substring(2).split(",");
+       path.push([parseInt(xy[0]), parseInt(xy[1]), 1]);
+     } else if (cmd.startsWith("PD")) {
+       // pen down
+       console.log("Pen down:", cmd.substring(2));
+       var pathRaw = cmd.substring(2).split(",");
+       for (var j = 0; j < pathRaw.length; j += 2) {
+         path.push([parseInt(pathRaw[j]), parseInt(pathRaw[j+1]), 0])
+       }
+     }
+   }
+   //
+   // parse size
+   //
+   var minX = path[0][0];
+   var maxX = path[0][0];
+   var minY = path[0][1];
+   var maxY = path[0][1];
+   for (var i = 1; i < path.length; i++) {
+     if (path[i][0] < minX) minX = path[i][0];
+     if (path[i][0] > maxX) maxX = path[i][0];
+     if (path[i][1] < minY) minY = path[i][1];
+     if (path[i][1] > maxY) maxY = path[i][1];
+   }
+   mod.width.nodeValue = "width: "+minX.toString() + " - " + maxX.toString();
+   mod.height.nodeValue = "height: "+minY.toString() + " - " + maxY.toString();
+   mod.maxX = maxX;
+   mod.maxY = maxY;
+   var x0 = minX;
+   var width = maxX-minX;
+   var y0 = minY;
+   var height = maxY-minY;
+   //
+   // display
+   //
+   var scaleX = mod.canvas.width / width;
+   var scaleY = mod.canvas.height / height;
+   var scale = scaleX > scaleY ? scaleY : scaleX;
+
+   var ctx = mod.canvas.getContext('2d');
+   ctx.clearRect(0,0,mod.canvas.width,mod.canvas.height)
+
+   ctx.beginPath();
+   ctx.moveTo((path[0][0] - x0) * scale, (path[0][1] - y0) * scale);
+   for (var i = 1; i < path.length; i++) {
+     if (path[i][2] == 0) {
+       ctx.lineTo((path[i][0] - x0) * scale, (path[i][1] - y0) * scale);
+     } else {
+       ctx.moveTo((path[i][0] - x0) * scale, (path[i][1] - y0) * scale);
+     }
+   }
+   ctx.strokeStyle = "red";
+   ctx.stroke();
+
+   mod.path = path;
+   outputs.toolpath.event();
+
+}
+//
+// return values
+//
+return ({
+   mod:mod,
+   name:name,
+   init:init,
+   inputs:inputs,
+   outputs:outputs,
+   interface:interface
+   })
+}())