diff --git a/files.html b/files.html
index 10778e2b9c492058df1b8c255e38029ae33074a7..4acc62db3a053887c940b0861aada2f516d5c732 100644
--- a/files.html
+++ b/files.html
@@ -178,6 +178,8 @@
 &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href='./modules/processes/mill/raster/2.5D'>2.5D</a><br>
 &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href='./modules/processes/mill/raster/2D'>2D</a><br>
 &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href='./modules/processes/mill/raster/3D'>3D</a><br>
+<i>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;scan</i><br>
+&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href='./modules/processes/scan/line'>line</a><br>
 <i>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;read</i><br>
 &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href='./modules/read/png'>png</a><br>
 &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href='./modules/read/stl'>stl</a><br>
diff --git a/modules/index.js b/modules/index.js
index 4ed6839a9c6bd64e57dc1dd2b0b3fbea0e78e9a8..6f1910f122eab31bc106bbd8c473667eb66fab18 100644
--- a/modules/index.js
+++ b/modules/index.js
@@ -148,6 +148,8 @@ module_label('      raster')
 module_menu('         2.5D','modules/processes/mill/raster/2.5D')
 module_menu('         2D','modules/processes/mill/raster/2D')
 module_menu('         3D','modules/processes/mill/raster/3D')
+module_label('   scan')
+module_menu('      line','modules/processes/scan/line')
 module_label('read')
 module_menu('   png','modules/read/png')
 module_menu('   stl','modules/read/stl')
diff --git a/programs/processes/scan/line b/programs/processes/scan/line
new file mode 100644
index 0000000000000000000000000000000000000000..b10a676d708e729fb86eb674df9b0514b92fc809
--- /dev/null
+++ b/programs/processes/scan/line
@@ -0,0 +1 @@
+{"modules":{"0.44700721850201686":{"definition":"//\n// line scan\n//\n// (c) MIT CBA Neil Gershenfeld 9/26/21\n// \n// This work may be reproduced, modified, distributed, performed, and \n// displayed for any purpose, but must acknowledge the fab modules \n// project. Copyright is retained and must be preserved. The work is \n// provided as is; no warranty is provided, and users accept all \n// liability.\n//\n// closure\n//\n(function(){\n//\n// module globals\n//\nvar mod = {}\n//\n// name\n//\nvar name = 'line scan'\n//\n// initialization\n//\nvar init = function() {\n   mod.width.value = 10\n   //\n   // open scan window\n   //\n   var win = window.open('')\n   win.document.title = \"scan window\"\n   win.document.body.appendChild(document.createElement('br'))\n   var canvas = document.createElement('canvas')\n   win.document.body.appendChild(canvas)\n   mod.scan = canvas\n   mod.win = win\n   }\n//\n// inputs\n//\nvar inputs = {\n   image:{type:'RGBA',\n      event:function(evt) {\n         scanloop(evt.detail)\n         }\n      }\n   }\n//\n// outputs\n//\nvar outputs = {\n   trigger:{type:'event',\n      event:function(){\n         mods.output(mod,'trigger',null)\n         }\n      }\n   }\n//\n// interface\n//\nvar interface = function(div){\n   mod.div = div\n   //\n   // on-screen drawing canvas\n   //\n   var canvas = document.createElement('canvas')\n      canvas.width = mods.ui.canvas\n      canvas.height = mods.ui.canvas\n      canvas.style.backgroundColor = 'rgb(255,255,255)'\n      div.appendChild(canvas)\n      mod.img = canvas\n   div.appendChild(document.createElement('br'))\n   //\n   // line width\n   //\n   div.appendChild(document.createTextNode('scan line width (pixels): '))\n   var input = document.createElement('input')\n      input.type = 'text'\n      input.size = 6\n      input.addEventListener('change',function(){\n         alert('width')\n         })\n      div.appendChild(input)\n      mod.width = input\n   //\n   // start scan button\n   //\n   div.appendChild(document.createElement('br'))\n   div.appendChild(document.createTextNode(' '))\n   var btn = document.createElement('button')\n      btn.style.padding = mods.ui.padding\n      btn.style.margin = 1\n      btn.appendChild(document.createTextNode('start scan'))\n      btn.addEventListener('click',function(){\n         linescan()\n         })\n      div.appendChild(btn)\n   }\n//\n// local functions\n//\n// line scan\n//\nfunction linescan() {\n   mod.l = parseFloat(mod.width.value)\n   mod.w = mod.win.innerWidth\n   mod.h = mod.win.innerHeight\n   mod.scan.width = mod.w\n   mod.scan.height = mod.h\n   mod.y = 0\n   mod.state = 'start'\n   scanloop(null)\n   }\n//\n// scan loop\n//\nfunction scanloop(input) {\n   if (mod.state == 'start') {\n      //\n      // take background\n      //\n      var ctx = mod.scan.getContext(\"2d\")\n      ctx.fillStyle = \"black\"\n      ctx.fillRect(0,0,mod.w,mod.h)\n      mod.state = 'background'\n      outputs.trigger.event()\n      }\n   else if (mod.state == 'background') {\n      //\n      // save background, start scan\n      //\n      mod.state = 'scan'\n      outputs.trigger.event()\n      }\n   else if (mod.state == 'scan') {\n      //\n      // scan\n      //\n      mod.y += mod.l\n      var ctx = mod.scan.getContext(\"2d\")\n      ctx.lineWidth = mod.l\n      ctx.fillStyle = \"black\"\n      ctx.fillRect(0,0,mod.w,mod.h)\n      ctx.strokeStyle = \"white\"\n      ctx.beginPath()\n      ctx.moveTo(0,mod.y)\n      ctx.lineTo(mod.w,mod.y)\n      ctx.stroke()\n      if (mod.y < mod.h)\n         outputs.trigger.event()\n      }\n   }\n//\n// return values\n//\nreturn ({\n   name:name,\n   init:init,\n   inputs:inputs,\n   outputs:outputs,\n   interface:interface\n   })\n}())\n","top":"162","left":"274","filename":"modules/processes/scan/line","inputs":{},"outputs":{}},"0.058167411259774315":{"definition":"//\n// video\n//\n// Neil Gershenfeld \n// (c) Massachusetts Institute of Technology 2015,6\n// \n// This work may be reproduced, modified, distributed, performed, and \n// displayed for any purpose, but must acknowledge the mods\n// project. Copyright is retained and must be preserved. The work is \n// provided as is; no warranty is provided, and users accept all \n// liability.\n//\n// closure\n//\n(function(){\n//\n// module globals\n//\nvar mod = {}\n//\n// name\n//\nvar name = 'video'\n//\n// initialization\n//\nvar init = function() {\n   mod.width.value = '640'\n   mod.height.value = '480'\n   mod.flip.checked = false\n   list_video()\n   }\n//\n// inputs\n//\nvar inputs = {\n   capture:{type:'event',\n      event:function(evt){\n         capture_video()}}}\n//\n// outputs\n//\nvar outputs = {\n   image:{type:'RGBA',\n      event:function(){\n         var ctx = mod.img.getContext(\"2d\")\n         var img = ctx.getImageData(0,0,mod.img.width,mod.img.height)\n         mods.output(mod,'image',img)}}}\n//\n// interface\n//\nvar interface = function(div){\n   mod.div = div\n   //\n   // on-screen drawing canvas\n   //\n   var canvas = document.createElement('canvas')\n      canvas.width = mods.ui.canvas\n      canvas.height = mods.ui.canvas\n      canvas.style.backgroundColor = 'rgb(255,255,255)'\n      div.appendChild(canvas)\n      mod.canvas = canvas\n   div.appendChild(document.createElement('br'))\n   //\n   // off-screen image canvas\n   //\n   var canvas = document.createElement('canvas')\n      mod.img = canvas\n   //\n   // camera select\n   //\n   var select = document.createElement('select')\n      div.appendChild(select)\n      mod.select = select\n   div.appendChild(document.createElement('br'))\n   //\n   // start button\n   //\n   var btn = document.createElement('button')\n      btn.style.padding = mods.ui.padding\n      btn.style.margin = 1\n      btn.appendChild(document.createTextNode('start'))\n      btn.addEventListener('click',function() {\n         start_video()\n         })\n      div.appendChild(btn)\n   div.appendChild(document.createTextNode(' '))\n   //\n   // capture button\n   //\n   var btn = document.createElement('button')\n      btn.style.padding = mods.ui.padding\n      btn.style.margin = 1\n      btn.appendChild(document.createTextNode('capture'))\n      btn.addEventListener('click',function() {\n         capture_video()\n         })\n      div.appendChild(btn)\n   div.appendChild(document.createTextNode(' '))\n   //\n   // view button\n   //\n   var btn = document.createElement('button')\n      btn.style.padding = mods.ui.padding\n      btn.style.margin = 1\n      btn.appendChild(document.createTextNode('view'))\n      btn.addEventListener('click',function(){\n         var win = window.open('')\n         var btn = document.createElement('button')\n            btn.appendChild(document.createTextNode('close'))\n            btn.style.padding = mods.ui.padding\n            btn.style.margin = 1\n            btn.addEventListener('click',function(){\n               win.close()\n               })\n            win.document.body.appendChild(btn)\n         win.document.body.appendChild(document.createElement('br'))\n         var canvas = document.createElement('canvas')\n            canvas.width = mod.img.width\n            canvas.height = mod.img.height\n            win.document.body.appendChild(canvas)\n         var ctx = canvas.getContext(\"2d\")\n            ctx.drawImage(mod.img,0,0)\n         })\n      div.appendChild(btn)\n   div.appendChild(document.createElement('br'))\n   //\n   // width\n   //\n   div.appendChild(document.createTextNode('width: '))\n   var input = document.createElement('input')\n      input.type = 'text'\n      input.size = 6\n      input.addEventListener('change',function() {\n         update_video()\n         })\n      div.appendChild(input)\n      mod.width = input\n   div.appendChild(document.createElement('br'))\n   //\n   // height\n   //\n   div.appendChild(document.createTextNode(' height: '))\n   var input = document.createElement('input')\n      input.type = 'text'\n      input.size = 6\n      input.addEventListener('change',function() {\n         update_video()\n         })\n      div.appendChild(input)\n      mod.height = input\n   div.appendChild(document.createElement('br'))\n   //\n   // flip\n   //\n   div.appendChild(document.createTextNode('flip image: '))\n   var input = document.createElement('input')\n      input.type = 'checkbox'\n      div.appendChild(input)\n      mod.flip = input\n   div.appendChild(document.createElement('br'))\n   //\n   // video element\n   //\n   var video = document.createElement('video')\n      mod.video = video\n   }\n//\n// local functions\n//\n// add cameras to list\n//\nfunction list_video() {\n   navigator.mediaDevices.enumerateDevices()\n      .then(function(devices) {\n         devices.forEach(function(device) {\n            if (device.kind == 'videoinput') {\n               var el = document.createElement('option')\n                   el.textContent = device.label\n                   el.value = device.deviceId\n                   mod.select.appendChild(el)\n               }\n            })\n         })\n   }\n//\n// start video\n//\nfunction start_video() {\n   var w = parseInt(mod.width.value)\n   var h = parseInt(mod.height.value)\n   var ctx = mod.img.getContext(\"2d\")\n   ctx.canvas.width = w\n   ctx.canvas.height = h\n   var constraints = {\n      audio:false,\n      video:{\n         width:w,height:h,\n         deviceId:{exact:mod.select.options[mod.select.selectedIndex].value}\n         }\n      }\n   navigator.mediaDevices.getUserMedia(constraints)\n      .then(function(stream) {\n         mod.video.srcObject = stream\n         mod.video.onloadedmetadata = function(e) {\n            mod.video.play()\n            capture_video()\n            }\n         })\n      .catch(function(err) {\n         console.log(err.name+': '+err.message)\n         })\n   }\n//\n// update video\n//\nfunction update_video() {\n   var w = parseInt(mod.width.value)\n   var h = parseInt(mod.height.value)\n   mod.video.setAttribute('width',w)\n   mod.video.setAttribute('height',h)\n   var ctx = mod.img.getContext(\"2d\")\n   ctx.canvas.width = w\n   ctx.canvas.height = h\n   }\n//\n// capture video\n//\nfunction capture_video() {\n   var w = parseInt(mod.width.value)\n   var h = parseInt(mod.height.value)\n   var ctx = mod.img.getContext(\"2d\")\n   ctx.drawImage(mod.video,0,0,w,h)\n   var blob = new Blob(['('+worker.toString()+'())'])\n   var url = window.URL.createObjectURL(blob)\n   var webworker = new Worker(url)\n   webworker.addEventListener('message',function(evt) {\n      window.URL.revokeObjectURL(url)\n      var h = mod.img.height\n      var w = mod.img.width\n      var buf = new Uint8ClampedArray(evt.data.buffer)\n      var imgdata = new ImageData(buf,w,h)\n      var ctx = mod.img.getContext(\"2d\")\n      ctx.putImageData(imgdata,0,0)\n      if (w > h) {\n         var x0 = 0\n         var y0 = mod.canvas.height*.5*(1-h/w)\n         var wd = mod.canvas.width\n         var hd = mod.canvas.width*h/w\n         }\n      else {\n         var x0 = mod.canvas.width*.5*(1-w/h)\n         var y0 = 0\n         var wd = mod.canvas.height*w/h\n         var hd = mod.canvas.height\n         }\n      var ctx = mod.canvas.getContext(\"2d\")\n      ctx.clearRect(0,0,mod.canvas.width,mod.canvas.height)\n      ctx.drawImage(mod.img,x0,y0,wd,hd)\n      outputs.image.event()\n      webworker.terminate()\n      })\n   var ctx = mod.img.getContext(\"2d\")\n   var img = ctx.getImageData(0,0,mod.img.width,mod.img.height)\n   webworker.postMessage({\n      height:mod.img.height,width:mod.img.width,\n      checked:mod.flip.checked,\n      buffer:img.data.buffer},[img.data.buffer])\n   }\nfunction worker() {\n   self.addEventListener('message',function(evt) {\n      var h = evt.data.height\n      var w = evt.data.width\n      var checked = evt.data.checked\n      var buf = new Uint8ClampedArray(evt.data.buffer)\n      if (checked == true) {\n         var newbuf = new Uint8ClampedArray(buf.length)\n         for (var row = 0; row < h; ++row) {\n            for (var col = 0; col < w; ++col) {\n               newbuf[(h-1-row)*w*4+col*4+0] = \n                  buf[row*w*4+(w-1-col)*4+0] \n               newbuf[(h-1-row)*w*4+col*4+1] = \n                  buf[row*w*4+(w-1-col)*4+1]\n               newbuf[(h-1-row)*w*4+col*4+2] = \n                  buf[row*w*4+(w-1-col)*4+2]\n               newbuf[(h-1-row)*w*4+col*4+3] = \n                  buf[row*w*4+(w-1-col)*4+3]\n               }\n            }\n         self.postMessage({buffer:newbuf.buffer},[newbuf.buffer])\n         }\n      else\n         self.postMessage({buffer:buf.buffer},[buf.buffer])\n      })\n   }\n//\n// return values\n//\nreturn ({\n   mod:mod,\n   name:name,\n   init:init,\n   inputs:inputs,\n   outputs:outputs,\n   interface:interface\n   })\n}())\n","top":"248","left":"773","filename":"modules/input/video","inputs":{},"outputs":{}}},"links":["{\"source\":\"{\\\"id\\\":\\\"0.44700721850201686\\\",\\\"type\\\":\\\"outputs\\\",\\\"name\\\":\\\"trigger\\\"}\",\"dest\":\"{\\\"id\\\":\\\"0.058167411259774315\\\",\\\"type\\\":\\\"inputs\\\",\\\"name\\\":\\\"capture\\\"}\"}","{\"source\":\"{\\\"id\\\":\\\"0.058167411259774315\\\",\\\"type\\\":\\\"outputs\\\",\\\"name\\\":\\\"image\\\"}\",\"dest\":\"{\\\"id\\\":\\\"0.44700721850201686\\\",\\\"type\\\":\\\"inputs\\\",\\\"name\\\":\\\"image\\\"}\"}"]}
\ No newline at end of file