diff --git a/files.html b/files.html
index d95bf984c3be18329209822e0461e10d7c08d58e..7739d47abd97386d5b5a45c22ed41c73b8b4fc7e 100644
--- a/files.html
+++ b/files.html
@@ -137,6 +137,7 @@
 &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href='./modules/math/parallel'>parallel</a><br>
 &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href='./modules/math/scalar'>scalar</a><br>
 <i>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;mesh</i><br>
+&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href='./modules/mesh/height%20map'>height map</a><br>
 &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href='./modules/mesh/rotate'>rotate</a><br>
 &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href='./modules/mesh/slice%20raster'>slice raster</a><br>
 <i>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;module</i><br>
diff --git a/modules/index.js b/modules/index.js
index 111e510642811b6e9ef7dc0992e5073863b88f0f..fad1081cbe1e2ba45a4e984a4c3e77d59ff2923f 100644
--- a/modules/index.js
+++ b/modules/index.js
@@ -106,6 +106,7 @@ module_menu('   glsl_benchmark','modules/math/glsl_benchmark')
 module_menu('   parallel','modules/math/parallel')
 module_menu('   scalar','modules/math/scalar')
 module_label('mesh')
+module_menu('   height map','modules/mesh/height%20map')
 module_menu('   rotate','modules/mesh/rotate')
 module_menu('   slice raster','modules/mesh/slice%20raster')
 module_label('module')
diff --git a/modules/mesh/height map b/modules/mesh/height map
new file mode 100644
index 0000000000000000000000000000000000000000..977e0195d7664f4b5da87f9183e9b6de27e82efe
--- /dev/null
+++ b/modules/mesh/height map	
@@ -0,0 +1,500 @@
+//
+// mesh height map
+// 
+// Neil Gershenfeld
+// 1/16/20
+//
+// 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 = 'mesh height map'
+//
+// initialization
+//
+var init = function() {
+   mod.mmunits.value = 25.4
+   mod.inunits.value = 1
+   mod.width.value = 1000
+   mod.border.value = 0
+   mod.delta = 1e-6
+   }
+//
+// inputs
+//
+var inputs = {
+   mesh:{type:'STL',
+      event:function(evt){
+         mod.mesh = new DataView(evt.detail)
+         find_limits_map()}},
+   settings:{type:'',
+      event:function(evt){
+         for (var p in evt.detail)
+            ;
+         find_limits_map()}}}
+//
+// outputs
+//
+var outputs = {
+   map:{type:'F32',
+      event:function(){
+         }},
+   image:{type:'RGBA',
+      event:function(){
+         var ctx = mod.img.getContext("2d")
+         var img = ctx.getImageData(0,0,mod.img.width,mod.img.height)
+         mods.output(mod,'image',img)
+         }},
+   imageInfo:{type:'',
+      event:function(){
+         var obj = {}
+         obj.name = "mesh height map"
+         obj.width = mod.img.width
+         obj.height = mod.img.height
+         obj.dpi = mod.img.width/(mod.dx*parseFloat(mod.inunits.value))
+         mods.output(mod,'imageInfo',obj)
+         }}}
+//
+// interface
+//
+var interface = function(div){
+   mod.div = div
+   //
+   // on-screen height map canvas
+   //
+   div.appendChild(document.createTextNode(' '))
+   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.mapcanvas = canvas
+   div.appendChild(document.createElement('br'))
+   //
+   // off-screen image canvas
+   //
+   var canvas = document.createElement('canvas')
+      mod.img = canvas
+   //
+   // mesh units
+   //
+   div.appendChild(document.createTextNode('mesh units: (enter)'))
+   div.appendChild(document.createElement('br'))
+   div.appendChild(document.createTextNode('mm: '))
+   var input = document.createElement('input')
+      input.type = 'text'
+      input.size = 6
+      input.addEventListener('change',function(){
+         mod.inunits.value = parseFloat(mod.mmunits.value)/25.4
+         find_limits_map()
+         })
+      div.appendChild(input)
+      mod.mmunits = input
+   div.appendChild(document.createTextNode(' in: '))
+   var input = document.createElement('input')
+      input.type = 'text'
+      input.size = 6
+      input.addEventListener('change',function(){
+         mod.mmunits.value = parseFloat(mod.inunits.value)*25.4
+         find_limits_map()
+         })
+      div.appendChild(input)
+      mod.inunits = input
+   //
+   // mesh size
+   //
+   div.appendChild(document.createElement('br'))
+   div.appendChild(document.createTextNode('mesh size:'))
+   div.appendChild(document.createElement('br'))
+   var text = document.createTextNode('XxYxZ (units)')
+      div.appendChild(text)
+      mod.meshsize = text
+   div.appendChild(document.createElement('br'))
+   var text = document.createTextNode('XxYxZ (mm)')
+      div.appendChild(text)
+      mod.mmsize = text
+   div.appendChild(document.createElement('br'))
+   var text = document.createTextNode('XxYxZ (in)')
+      div.appendChild(text)
+      mod.insize = text
+   //
+   // height map border 
+   //
+   div.appendChild(document.createElement('br'))
+   div.appendChild(document.createTextNode('border: '))
+   var input = document.createElement('input')
+      input.type = 'text'
+      input.size = 6
+      input.addEventListener('change',function(){
+         find_limits_map()
+         })
+      div.appendChild(input)
+      mod.border = input
+   div.appendChild(document.createTextNode(' (units)'))
+   //
+   // height map width
+   //
+   div.appendChild(document.createElement('br'))
+   div.appendChild(document.createTextNode('width: '))
+   var input = document.createElement('input')
+      input.type = 'text'
+      input.size = 6
+      input.addEventListener('change',function(){
+         find_limits_map()
+         })
+      div.appendChild(input)
+      mod.width = input
+   div.appendChild(document.createTextNode(' (pixels)'))
+   //
+   // view height map
+   //
+   div.appendChild(document.createElement('br'))
+   var btn = document.createElement('button')
+      btn.style.padding = mods.ui.padding
+      btn.style.margin = 1
+      btn.appendChild(document.createTextNode('view height map'))
+      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)
+   }
+//
+// local functions
+//
+// find limits then map 
+//
+function find_limits_map() {
+   var blob = new Blob(['('+limits_worker.toString()+'())'])
+   var url = window.URL.createObjectURL(blob)
+   var webworker = new Worker(url)
+   webworker.addEventListener('message',function(evt) {
+      window.URL.revokeObjectURL(url)
+      mod.triangles = evt.data.triangles
+      mod.xmin = evt.data.xmin
+      mod.xmax = evt.data.xmax
+      mod.ymin = evt.data.ymin
+      mod.ymax = evt.data.ymax
+      mod.zmin = evt.data.zmin
+      mod.zmax = evt.data.zmax
+      mod.dx = mod.xmax-mod.xmin
+      mod.dy = mod.ymax-mod.ymin
+      mod.dz = mod.zmax-mod.zmin
+      mod.meshsize.nodeValue = 
+         mod.dx.toFixed(3)+' x '+
+         mod.dy.toFixed(3)+' x '+
+         mod.dz.toFixed(3)+' (units)'
+      var mm = parseFloat(mod.mmunits.value)
+      mod.mmsize.nodeValue = 
+         (mod.dx*mm).toFixed(3)+' x '+
+         (mod.dy*mm).toFixed(3)+' x '+
+         (mod.dz*mm).toFixed(3)+' (mm)'
+      var inches = parseFloat(mod.inunits.value)
+      mod.insize.nodeValue = 
+         (mod.dx*inches).toFixed(3)+' x '+
+         (mod.dy*inches).toFixed(3)+' x '+
+         (mod.dz*inches).toFixed(3)+' (in)'
+      mods.fit(mod.div)
+      map_mesh()
+      })
+   var border = parseFloat(mod.border.value)
+   webworker.postMessage({
+      mesh:mod.mesh,
+      border:border,delta:mod.delta})
+   }
+function limits_worker() {
+   self.addEventListener('message',function(evt) {
+      var view = evt.data.mesh
+      var border = evt.data.border
+      var delta = evt.data.delta // perturb to remove degeneracies
+      //
+      // get vars
+      //
+      var endian = true
+      var triangles = view.getUint32(80,endian)
+      var size = 80+4+triangles*(4*12+2)
+      //
+      // find limits
+      //
+      var offset = 80+4
+      var x0,x1,x2,y0,y1,y2,z0,z1,z2
+      var xmin = Number.MAX_VALUE
+      var xmax = -Number.MAX_VALUE
+      var ymin = Number.MAX_VALUE
+      var ymax = -Number.MAX_VALUE
+      var zmin = Number.MAX_VALUE
+      var zmax = -Number.MAX_VALUE
+      for (var t = 0; t < triangles; ++t) {
+         offset += 3*4
+         x0 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         y0 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         z0 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         x1 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         y1 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         z1 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         x2 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         y2 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         z2 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         offset += 2
+         if (x0 > xmax) xmax = x0
+         if (x0 < xmin) xmin = x0
+         if (y0 > ymax) ymax = y0
+         if (y0 < ymin) ymin = y0
+         if (z0 > zmax) zmax = z0
+         if (z0 < zmin) zmin = z0
+         if (x1 > xmax) xmax = x1
+         if (x1 < xmin) xmin = x1
+         if (y1 > ymax) ymax = y1
+         if (y1 < ymin) ymin = y1
+         if (z1 > zmax) zmax = z1
+         if (z1 < zmin) zmin = z1
+         if (x2 > xmax) xmax = x2
+         if (x2 < xmin) xmin = x2
+         if (y2 > ymax) ymax = y2
+         if (y2 < ymin) ymin = y2
+         if (z2 > zmax) zmax = z2
+         if (z2 < zmin) zmin = z2
+         }
+      xmin -= border
+      xmax += border
+      ymin -= border
+      ymax += border
+      //
+      // return
+      //
+      self.postMessage({triangles:triangles,
+         xmin:xmin,xmax:xmax,ymin:ymin,ymax:ymax,
+         zmin:zmin,zmax:zmax})
+      self.close()
+      })
+   }
+//
+// map mesh
+//   
+function map_mesh() {
+   var blob = new Blob(['('+map_worker.toString()+'())'])
+   var url = window.URL.createObjectURL(blob)
+   var webworker = new Worker(url)
+   webworker.addEventListener('message',function(evt) {
+      window.URL.revokeObjectURL(url)
+      var h = mod.img.height
+      var w = mod.img.width
+      var buf = new Uint8ClampedArray(evt.data.buffer)
+      var imgdata = new ImageData(buf,w,h)
+      var ctx = mod.img.getContext("2d")
+      ctx.putImageData(imgdata,0,0)
+      if (w > h) {
+         var x0 = 0
+         var y0 = mod.mapcanvas.height*.5*(1-h/w)
+         var wd = mod.mapcanvas.width
+         var hd = mod.mapcanvas.width*h/w
+         }
+      else {
+         var x0 = mod.mapcanvas.width*.5*(1-w/h)
+         var y0 = 0
+         var wd = mod.mapcanvas.height*w/h
+         var hd = mod.mapcanvas.height
+         }
+      var ctx = mod.mapcanvas.getContext("2d")
+      ctx.clearRect(0,0,mod.mapcanvas.width,mod.mapcanvas.height)
+      ctx.drawImage(mod.img,x0,y0,wd,hd)
+      outputs.image.event()
+      outputs.imageInfo.event()
+      })
+   var ctx = mod.mapcanvas.getContext("2d")
+   ctx.clearRect(0,0,mod.mapcanvas.width,mod.mapcanvas.height)
+   mod.img.width = parseInt(mod.width.value)
+   mod.img.height = Math.round(mod.img.width*mod.dy/mod.dx)
+   var ctx = mod.img.getContext("2d")
+   var img = ctx.getImageData(0,0,mod.img.width,mod.img.height)
+   webworker.postMessage({
+      height:mod.img.height,width:mod.img.width,
+      imgbuffer:img.data.buffer,mesh:mod.mesh,
+      xmin:mod.xmin,xmax:mod.xmax,
+      ymin:mod.ymin,ymax:mod.ymax,
+      zmin:mod.zmin,zmax:mod.zmax,
+      delta:mod.delta},
+      [img.data.buffer])
+   }
+function map_worker() {
+   self.addEventListener('message',function(evt) {
+      var h = evt.data.height
+      var w = evt.data.width
+      var view = evt.data.mesh
+      var delta = evt.data.delta // perturb to remove degeneracies
+      var xmin = evt.data.xmin
+      var xmax = evt.data.xmax
+      var ymin = evt.data.ymin
+      var ymax = evt.data.ymax
+      var zmin = evt.data.zmin
+      var zmax = evt.data.zmax
+      var buf = new Uint8ClampedArray(evt.data.imgbuffer)
+      //
+      // get vars from buffer
+      //
+      var endian = true
+      var triangles = view.getUint32(80,endian)
+      var size = 80+4+triangles*(4*12+2)
+      //
+      // initialize map image
+      //
+      for (var row = 0; row < h; ++row) {
+         for (var col = 0; col < w; ++col) {
+            buf[(h-1-row)*w*4+col*4+0] = row
+            buf[(h-1-row)*w*4+col*4+1] = col
+            buf[(h-1-row)*w*4+col*4+2] = 0
+            buf[(h-1-row)*w*4+col*4+3] = 255
+            }
+         }
+      /*
+      //
+      // find triangles crossing the map
+      //
+      var segs = []
+      offset = 80+4
+      for (var t = 0; t < triangles; ++t) {
+         offset += 3*4
+         x0 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         y0 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         z0 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         x1 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         y1 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         z1 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         x2 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         y2 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         z2 = view.getFloat32(offset,endian)+delta
+         offset += 4
+         //
+         // assemble vertices
+         //
+         offset += 2
+         var v = [[x0,y0,z0],[x1,y1,z1],[x2,y2,z2]]
+         //
+         // sort z
+         //
+         v.sort(function(a,b) {
+            if (a[2] < b[2])
+               return -1
+            else if (a[2] > b[2])
+               return 1
+            else
+               return 0
+            })
+         //
+         // check for crossings
+         //
+         if ((v[0][2] < (zmax-depth)) && (v[2][2] > (zmax-depth))) {
+            //
+            //  crossing found, check for side and save
+            //
+            if (v[1][2] < (zmax-depth)) {
+               var x0 = v[2][0]+(v[0][0]-v[2][0])
+                  *(v[2][2]-(zmax-depth))/(v[2][2]-v[0][2])
+               var y0 = v[2][1]+(v[0][1]-v[2][1])
+                  *(v[2][2]-(zmax-depth))/(v[2][2]-v[0][2])
+               var x1 = v[2][0]+(v[1][0]-v[2][0])
+                  *(v[2][2]-(zmax-depth))/(v[2][2]-v[1][2])
+               var y1 = v[2][1]+(v[1][1]-v[2][1])
+                  *(v[2][2]-(zmax-depth))/(v[2][2]-v[1][2])
+               }
+            else if (v[1][2] >= (zmax-depth)) {
+               var x0 = v[2][0]+(v[0][0]-v[2][0])
+                  *(v[2][2]-(zmax-depth))/(v[2][2]-v[0][2])
+               var y0 = v[2][1]+(v[0][1]-v[2][1])
+                  *(v[2][2]-(zmax-depth))/(v[2][2]-v[0][2])
+               var x1 = v[1][0]+(v[0][0]-v[1][0])
+                  *(v[1][2]-(zmax-depth))/(v[1][2]-v[0][2])
+               var y1 = v[1][1]+(v[0][1]-v[1][1])
+                  *(v[1][2]-(zmax-depth))/(v[1][2]-v[0][2])
+               }
+            if (y0 < y1)
+               segs.push({x0:x0,y0:y0,x1:x1,y1:y1})
+            else
+               segs.push({x0:x1,y0:y1,x1:x0,y1:y0})
+            }
+         }
+      //
+      // fill interior
+      //
+      for (var row = 0; row < h; ++row) {
+         var y = ymin+(ymax-ymin)*row/(h-1)
+         rowsegs = segs.filter(p => ((p.y0 <= y) && (p.y1 >= y)))
+         var xs = rowsegs.map(p =>
+            (p.x0+(p.x1-p.x0)*(y-p.y0)/(p.y1-p.y0)))
+         xs.sort((a,b) => (a-b))
+         for (var col = 0; col < w; ++col) {
+            var x = xmin+(xmax-xmin)*col/(w-1)
+            var index = xs.findIndex((p) => (p >= x))
+            if (index == -1)
+               var i = 0
+            else
+               var i = 255*(index%2)
+            buf[(h-1-row)*w*4+col*4+0] = i
+            buf[(h-1-row)*w*4+col*4+1] = i
+            buf[(h-1-row)*w*4+col*4+2] = i
+            buf[(h-1-row)*w*4+col*4+3] = 255
+            }
+         }
+      */
+      //
+      // output the map
+      //
+      self.postMessage({buffer:buf.buffer},[buf.buffer])
+      self.close()
+      })
+   }
+//
+// return values
+//
+return ({
+   mod:mod,
+   name:name,
+   init:init,
+   inputs:inputs,
+   outputs:outputs,
+   interface:interface
+   })
+}())