diff --git a/.github/workflows/checkdocs.yml b/.github/workflows/main.yml
similarity index 63%
rename from .github/workflows/checkdocs.yml
rename to .github/workflows/main.yml
index 2e5c707..c44bc54 100644
--- a/.github/workflows/checkdocs.yml
+++ b/.github/workflows/main.yml
@@ -8,7 +8,34 @@ on: [push]
 
 # A workflow run is made up of one or more jobs that can run sequentially or in parallel
 jobs:
-  # This workflow contains a single job called "gendocs"
+  Regressions:
+    # The type of runner that the job will run on
+    runs-on: ubuntu-latest
+    steps:
+    - name: Checkout
+      uses: actions/checkout@v2
+
+    - name: Wiki Checkout
+      run: |
+        cd $GITHUB_WORKSPACE
+        git clone https://github.com/revarbat/BOSL2.wiki.git
+
+    - name: Install wget
+      run: sudo apt-get install wget
+
+    - name: Get OpenSCAD Appimage
+      run: |
+        cd $GITHUB_WORKSPACE
+        wget https://files.openscad.org/OpenSCAD-2019.05-x86_64.AppImage
+        sudo mv OpenSCAD-2019.05-x86_64.AppImage /usr/local/bin/openscad
+        sudo chmod +x /usr/local/bin/openscad
+
+    - name: Run Regression Tests
+      run: |
+        cd $GITHUB_WORKSPACE
+        export OPENSCADPATH=$(dirname $GITHUB_WORKSPACE)
+        ./scripts/run_tests.sh
+
   CheckDocs:
     # The type of runner that the job will run on
     runs-on: ubuntu-latest
@@ -36,15 +63,15 @@ jobs:
     - name: Install Pillow
       run: sudo pip3 install Pillow
 
-    - name: Install wget
-      run: sudo apt-get install wget
+    # - name: Install wget
+    #   run: sudo apt-get install wget
 
-    - name: Get OpenSCAD Appimage
+    - name: Install OpenSCAD
       run: |
         cd $GITHUB_WORKSPACE
         wget https://files.openscad.org/OpenSCAD-2019.05-x86_64.AppImage
-        mv OpenSCAD-2019.05-x86_64.AppImage openscad
-        chmod +x openscad
+        sudo mv OpenSCAD-2019.05-x86_64.AppImage /usr/local/bin/openscad
+        sudo chmod +x /usr/local/bin/openscad
 
     - name: Generate Index
       run: |
@@ -54,8 +81,6 @@ jobs:
     - name: Generating Docs
       run: |
         cd $GITHUB_WORKSPACE
-        export PATH=$GITHUB_WORKSPACE:$PATH
         export OPENSCADPATH=$(dirname $GITHUB_WORKSPACE)
-        echo "Using OPENSCADPATH=$OPENSCADPATH"
-        ./scripts/make_all_docs.sh -i
+        ./scripts/make_all_docs.sh -t -i
 
diff --git a/affine.scad b/affine.scad
index c25f667..4a1a7ff 100644
--- a/affine.scad
+++ b/affine.scad
@@ -245,13 +245,14 @@ function affine3d_rot_from_to(from, to) =
 
 
 // Function: affine_frame_map()
-// Usage: map = affine_frame_map(x=v1,y=v2);
-//        map = affine_frame_map(x=v1,z=v2);
-//        map = affine_frame_map(y=v1,y=v2);
-//        map = affine_frame_map(v1,v2,v3);
+// Usage:
+//   map = affine_frame_map(x=v1,y=v2);
+//   map = affine_frame_map(x=v1,z=v2);
+//   map = affine_frame_map(y=v1,y=v2);
+//   map = affine_frame_map(v1,v2,v3);
 // Description:
 //   Returns a transformation that maps one coordinate frame to another.  You must specify two or three of `x`, `y`, and `z`.  The specified
-//   axes are mapped to the vectors you supplied.  If you give two inputs, the third vector is mapped to the appropriate normal to maintain a right hand coordinate system.  
+//   axes are mapped to the vectors you supplied.  If you give two inputs, the third vector is mapped to the appropriate normal to maintain a right hand coordinate system.
 //   If the vectors you give are orthogonal the result will be a rotation and the `reverse` parameter will supply the inverse map, which enables you
 //   to map two arbitrary coordinate systems to each other by using the canonical coordinate system as an intermediary.  You cannot use the `reverse` option 
 //   with non-orthogonal inputs.  
@@ -261,8 +262,8 @@ function affine3d_rot_from_to(from, to) =
 //   z = Destination vector for z axis
 //   reverse = reverse direction of the map for orthogonal inputs.  Default: false
 // Examples:
-//   T = affine_frame_map(x=[1,1,0], y=[-1,1]);   // This map is just a rotation around the z axis
-//   T = affine_frame_map(x=[1,0,0], y=[1,1]);    // This map is not a rotation because x and y aren't orthogonal
+//   T = affine_frame_map(x=[1,1,0], y=[-1,1,0]);   // This map is just a rotation around the z axis
+//   T = affine_frame_map(x=[1,0,0], y=[1,1,0]);    // This map is not a rotation because x and y aren't orthogonal
 //                  // The next map sends [1,1,0] to [0,1,1] and [-1,1,0] to [0,-1,1]
 //   T = affine_frame_map(x=[0,1,1], y=[0,-1,1]) * affine_frame_map(x=[1,1,0], y=[-1,1,0],reverse=true);
 function affine_frame_map(x,y,z, reverse=false) =
@@ -319,7 +320,7 @@ function affine3d_mirror(v) =
 
 // Function: affine3d_skew()
 // Usage:
-//   mat = affine3d_skew([sxy], [sxz], [syx], [xyz], [szx], [szy]);
+//   mat = affine3d_skew([sxy], [sxz], [syx], [syz], [szx], [szy]);
 // Description:
 //   Returns the 4x4 affine3d matrix to perform a skew transformation.
 // Arguments:
@@ -433,6 +434,7 @@ function apply(transform,points) =
 //   transformed = apply_list(path3d(circle(r=3)),[xrot(45)]);        // Rotates 3d circle data around x axis
 //   transformed = apply_list(circle(r=3), [scale(3), right(4), rot(45)]); // Scales, then translates, and then rotates 2d circle data
 function apply_list(points,transform_list) =
+  transform_list == []? points :
   is_vector(points) ? apply_list([points],transform_list)[0] :
   let(
       tdims = array_dim(transform_list),
diff --git a/scripts/docs_gen.py b/scripts/docs_gen.py
index 883da62..d2f819c 100755
--- a/scripts/docs_gen.py
+++ b/scripts/docs_gen.py
@@ -127,22 +127,29 @@ def git_checkout(filename):
     err = p.stdout.read()
 
 
-def run_openscad_script(libfile, infile, imgfile, imgsize=(320,240), eye=None, show_edges=False, render=False):
-    scadcmd = [
-        OPENSCAD,
-        "-o", imgfile,
-        "--imgsize={},{}".format(imgsize[0]*2, imgsize[1]*2),
-        "--hardwarnings",
-        "--projection=o",
-        "--autocenter",
-        "--viewall"
-    ]
-    if eye is not None:
-        scadcmd.extend(["--camera", eye+",0,0,0"])
-    if show_edges:
-        scadcmd.extend(["--view=axes,scales,edges"])
+def run_openscad_script(libfile, infile, imgfile, imgsize=(320,240), eye=None, show_edges=False, render=False, test_only=False):
+    if test_only:
+        scadcmd = [
+            OPENSCAD,
+            "-o", "foo.term",
+            "--hardwarnings"
+        ]
     else:
-        scadcmd.extend(["--view=axes,scales"])
+        scadcmd = [
+            OPENSCAD,
+            "-o", imgfile,
+            "--imgsize={},{}".format(imgsize[0]*2, imgsize[1]*2),
+            "--hardwarnings",
+            "--projection=o",
+            "--autocenter",
+            "--viewall"
+        ]
+        if eye is not None:
+            scadcmd.extend(["--camera", eye+",0,0,0"])
+        if show_edges:
+            scadcmd.extend(["--view=axes,scales,edges"])
+        else:
+            scadcmd.extend(["--view=axes,scales"])
     if render:  # Force render
         scadcmd.extend(["--render", ""])
     scadcmd.append(infile)
@@ -151,6 +158,8 @@ def run_openscad_script(libfile, infile, imgfile, imgsize=(320,240), eye=None, s
     p = subprocess.Popen(scadcmd, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True)
     (stdoutdata, stderrdata) = p.communicate(None)
     res = p.returncode
+    if test_only and os.path.isfile("foo.term"):
+        os.unlink("foo.term")
     if res != 0 or b"ERROR:" in stderrdata or b"TRACE:" in stderrdata:
         print("\n\n{}".format(stderrdata.decode('utf-8')))
         print("////////////////////////////////////////////////////")
@@ -177,6 +186,7 @@ class ImageProcessing(object):
         self.imgroot = ""
         self.keep_scripts = False
         self.force = False
+        self.test_only = False
 
     def set_keep_scripts(self, x):
         self.keep_scripts = x
@@ -187,9 +197,10 @@ class ImageProcessing(object):
     def set_commoncode(self, code):
         self.commoncode = code
 
-    def process_examples(self, imgroot, force=False):
+    def process_examples(self, imgroot, force=False, test_only=False):
         self.imgroot = imgroot
         self.force = force
+        self.test_only = test_only
         self.hashes = {}
         with dbm.gnu.open("examples_hashes.gdbm", "c") as db:
             for libfile, imgfile, code, extype in self.examples:
@@ -204,6 +215,7 @@ class ImageProcessing(object):
         print("  {}".format(imgfile), end='')
         sys.stdout.flush()
 
+        test_only = self.test_only
         scriptfile = "tmp_{0}.scad".format(imgfile.replace(".", "_"))
         targimgfile = self.imgroot + imgfile
         newimgfile = self.imgroot + "_new_" + imgfile
@@ -248,7 +260,7 @@ class ImageProcessing(object):
         render = "FR" in extype
 
         tmpimgs = []
-        if "Spin" in extype:
+        if "Spin" in extype and not test_only:
             for ang in range(0,359,10):
                 tmpimgfile = "{0}tmp_{2}_{1}.png".format(self.imgroot, ang, imgfile.replace(".", "_"))
                 arad = ang * math.pi / 180;
@@ -262,7 +274,8 @@ class ImageProcessing(object):
                     imgsize=(imgsize[0]*2,imgsize[1]*2),
                     eye=eye,
                     show_edges=show_edges,
-                    render=render
+                    render=render,
+                    test_only=test_only
                 )
                 tmpimgs.append(tmpimgfile)
                 print(".", end='')
@@ -275,39 +288,42 @@ class ImageProcessing(object):
                 imgsize=(imgsize[0]*2,imgsize[1]*2),
                 eye=eye,
                 show_edges=show_edges,
-                render=render
+                render=render,
+                test_only=test_only
             )
             tmpimgs.append(tmpimgfile)
 
         if not self.keep_scripts:
             os.unlink(scriptfile)
 
-        if len(tmpimgs) == 1:
-            image_resize(tmpimgfile, newimgfile, imgsize)
-            os.unlink(tmpimgs.pop(0))
-        else:
-            make_animated_gif(tmpimgs, newimgfile, size=imgsize)
-            for tmpimg in tmpimgs:
-                os.unlink(tmpimg)
+        if not test_only:
+            if len(tmpimgs) == 1:
+                image_resize(tmpimgfile, newimgfile, imgsize)
+                os.unlink(tmpimgs.pop(0))
+            else:
+                make_animated_gif(tmpimgs, newimgfile, size=imgsize)
+                for tmpimg in tmpimgs:
+                    os.unlink(tmpimg)
 
         print("")
 
-        # Time to compare image.
-        if not os.path.isfile(targimgfile):
-            print("    NEW IMAGE\n")
-            os.rename(newimgfile, targimgfile)
-        else:
-            if targimgfile.endswith(".gif"):
-                issame = filecmp.cmp(targimgfile, newimgfile, shallow=False)
-            else:
-                issame  = image_compare(targimgfile, newimgfile);
-            if issame:
-                os.unlink(newimgfile)
-            else:
-                print("    UPDATED IMAGE\n")
-                os.unlink(targimgfile)
+        if not test_only:
+            # Time to compare image.
+            if not os.path.isfile(targimgfile):
+                print("    NEW IMAGE\n")
                 os.rename(newimgfile, targimgfile)
-        self.hashes[key] = hash
+            else:
+                if targimgfile.endswith(".gif"):
+                    issame = filecmp.cmp(targimgfile, newimgfile, shallow=False)
+                else:
+                    issame  = image_compare(targimgfile, newimgfile);
+                if issame:
+                    os.unlink(newimgfile)
+                else:
+                    print("    UPDATED IMAGE\n")
+                    os.unlink(targimgfile)
+                    os.rename(newimgfile, targimgfile)
+            self.hashes[key] = hash
 
 
 imgprc = ImageProcessing()
@@ -772,7 +788,7 @@ class LibFile(object):
         return out
 
 
-def processFile(infile, outfile=None, gen_imgs=False, imgroot="", prefix="", force=False):
+def processFile(infile, outfile=None, gen_imgs=False, test_only=False, imgroot="", prefix="", force=False):
     if imgroot and not imgroot.endswith('/'):
         imgroot += "/"
 
@@ -792,7 +808,7 @@ def processFile(infile, outfile=None, gen_imgs=False, imgroot="", prefix="", for
         print(line, file=f)
 
     if gen_imgs:
-        imgprc.process_examples(imgroot, force=force)
+        imgprc.process_examples(imgroot, force=force, test_only=test_only)
 
     if outfile:
         f.close()
@@ -800,6 +816,8 @@ def processFile(infile, outfile=None, gen_imgs=False, imgroot="", prefix="", for
 
 def main():
     parser = argparse.ArgumentParser(prog='docs_gen')
+    parser.add_argument('-t', '--test-only', action="store_true",
+                        help="If given, don't generate images, but do try executing the scripts.")
     parser.add_argument('-k', '--keep-scripts', action="store_true",
                         help="If given, don't delete the temporary image OpenSCAD scripts.")
     parser.add_argument('-c', '--comments-only', action="store_true",
@@ -820,6 +838,7 @@ def main():
         args.infile,
         outfile=args.outfile,
         gen_imgs=args.images,
+        test_only=args.test_only,
         imgroot=args.imgroot,
         prefix="// " if args.comments_only else "",
         force=args.force
diff --git a/scripts/make_all_docs.sh b/scripts/make_all_docs.sh
index 177604a..afb581c 100755
--- a/scripts/make_all_docs.sh
+++ b/scripts/make_all_docs.sh
@@ -2,12 +2,14 @@
 
 FORCED=""
 IMGGEN=""
+TESTONLY=""
 FILES=""
 DISPMD=""
 for opt in "$@" ; do
   case $opt in
     -f ) FORCED=$opt ;;
     -i ) IMGGEN=$opt ;;
+    -t ) TESTONLY=$opt ;;
     -d ) DISPMD=$opt ;;
     -* ) echo "Unknown option $opt"; exit -1 ;;
     * ) FILES="$FILES $opt" ;;
@@ -32,11 +34,11 @@ rm -f tmpscad*.scad
 for lib in $PREVIEW_LIBS; do
     lib="$(basename $lib .scad)"
     mkdir -p images/$lib
-    if [ "$IMGGEN" != "" ]; then
+    if [ "$IMGGEN" != ""  -a "$TESTONLY" != ""]; then
         rm -f images/$lib/*.png images/$lib/*.gif
     fi
     echo "$lib.scad"
-    ../scripts/docs_gen.py ../$lib.scad -o $lib.scad.md -c $IMGGEN $FORCED -I images/$lib/ || exit 1
+    ../scripts/docs_gen.py ../$lib.scad -o $lib.scad.md -c $IMGGEN $FORCED $TESTONLY -I images/$lib/ || exit 1
     if [ "$DISPMD" != "" ]; then
         open -a Typora $lib.scad.md
     fi
diff --git a/tests/test_affine.scad b/tests/test_affine.scad
new file mode 100644
index 0000000..a37153c
--- /dev/null
+++ b/tests/test_affine.scad
@@ -0,0 +1,256 @@
+include <BOSL2/std.scad>
+
+
+module test_ident() {
+    assert(ident(3) == [[1,0,0],[0,1,0],[0,0,1]]);
+    assert(ident(4) == [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]);
+}
+test_ident();
+
+
+module test_is_2d_transform() {
+    assert(!is_2d_transform(affine2d_identity()));
+    assert(!is_2d_transform(affine2d_translate([5,8])));
+    assert(!is_2d_transform(affine2d_scale([3,4])));
+    assert(!is_2d_transform(affine2d_zrot(30)));
+    assert(!is_2d_transform(affine2d_mirror([-1,1])));
+    assert(!is_2d_transform(affine2d_skew(30,15)));
+
+    assert(is_2d_transform(affine3d_identity()));
+    assert(is_2d_transform(affine3d_translate([30,40,0])));
+    assert(!is_2d_transform(affine3d_translate([30,40,50])));
+    assert(is_2d_transform(affine3d_scale([3,4,1])));
+    assert(!is_2d_transform(affine3d_xrot(30)));
+    assert(!is_2d_transform(affine3d_yrot(30)));
+    assert(is_2d_transform(affine3d_zrot(30)));
+    assert(is_2d_transform(affine3d_skew(sxy=2)));
+    assert(is_2d_transform(affine3d_skew(syx=2)));
+    assert(!is_2d_transform(affine3d_skew(szx=2)));
+    assert(!is_2d_transform(affine3d_skew(szy=2)));
+}
+test_is_2d_transform();
+
+
+module test_affine2d_to_3d() {
+    assert(affine2d_to_3d(affine2d_identity()) == affine3d_identity());
+    assert(affine2d_to_3d(affine2d_zrot(30)) == affine3d_zrot(30));
+}
+test_affine2d_to_3d();
+
+
+// 2D
+
+module test_affine2d_identity() {
+    assert(affine2d_identity() == [[1,0,0],[0,1,0],[0,0,1]]);
+}
+test_affine2d_identity();
+
+
+module test_affine2d_translate() {
+    assert(affine2d_translate([0,0]) == [[1,0,0],[0,1,0],[0,0,1]]);
+    assert(affine2d_translate([10,20]) == [[1,0,10],[0,1,20],[0,0,1]]);
+    assert(affine2d_translate([20,10]) == [[1,0,20],[0,1,10],[0,0,1]]);
+}
+test_affine2d_translate();
+
+
+module test_affine2d_scale() {
+    assert(affine2d_scale([1,1]) == [[1,0,0],[0,1,0],[0,0,1]]);
+    assert(affine2d_scale([2,3]) == [[2,0,0],[0,3,0],[0,0,1]]);
+    assert(affine2d_scale([5,4]) == [[5,0,0],[0,4,0],[0,0,1]]);
+}
+test_affine2d_scale();
+
+
+module test_affine2d_mirror() {
+    assert(approx(affine2d_mirror([1,1]),[[0,-1,0],[-1,0,0],[0,0,1]]));
+    assert(affine2d_mirror([1,0]) == [[-1,0,0],[0,1,0],[0,0,1]]);
+    assert(affine2d_mirror([0,1]) == [[1,0,0],[0,-1,0],[0,0,1]]);
+}
+test_affine2d_mirror();
+
+
+module test_affine2d_zrot() {
+    for(a = [-360:2/3:360]) {
+        assert(affine2d_zrot(a) == [[cos(a),-sin(a),0],[sin(a),cos(a),0],[0,0,1]]);
+    }
+}
+test_affine2d_zrot();
+
+
+module test_affine2d_skew() {
+    for(ya = [-89:3:89]) {
+        for(xa = [-89:3:89]) {
+            assert(affine2d_skew(xa=xa, ya=ya) == [[1,tan(xa),0],[tan(ya),1,0],[0,0,1]]);
+        }
+    }
+}
+test_affine2d_skew();
+
+
+module test_affine2d_chain() {
+    t = affine2d_translate([15,30]);
+    s = affine2d_scale([1.5,2]);
+    r = affine2d_zrot(30);
+    assert(affine2d_chain([t,s,r]) == r * s * t);
+}
+test_affine2d_chain();
+
+
+// 3D
+
+module test_affine3d_identity() {
+    assert(affine3d_identity() == [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]);
+}
+test_affine3d_identity();
+
+
+module test_affine3d_translate() {
+    assert(affine3d_translate([10,20,30]) == [[1,0,0,10],[0,1,0,20],[0,0,1,30],[0,0,0,1]]);
+    assert(affine3d_translate([3,2,1]) == [[1,0,0,3],[0,1,0,2],[0,0,1,1],[0,0,0,1]]);
+}
+test_affine3d_translate();
+
+
+module test_affine3d_scale() {
+    assert(affine3d_scale([3,2,4]) == [[3,0,0,0],[0,2,0,0],[0,0,4,0],[0,0,0,1]]);
+}
+test_affine3d_scale();
+
+
+module test_affine3d_mirror() {
+    assert(affine3d_mirror([1,0,0]) == [[-1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]);
+    assert(affine3d_mirror([0,1,0]) == [[1,0,0,0],[0,-1,0,0],[0,0,1,0],[0,0,0,1]]);
+    assert(affine3d_mirror([0,0,1]) == [[1,0,0,0],[0,1,0,0],[0,0,-1,0],[0,0,0,1]]);
+    assert(approx(affine3d_mirror([1,1,1]), [[1/3,-2/3,-2/3,0],[-2/3,1/3,-2/3,0],[-2/3,-2/3,1/3,0],[0,0,0,1]]));
+}
+test_affine3d_mirror();
+
+
+module test_affine3d_xrot() {
+    for(a = [-360:2/3:360]) {
+        assert(approx(affine3d_xrot(a), [[1,0,0,0],[0,cos(a),-sin(a),0],[0,sin(a),cos(a),0],[0,0,0,1]]));
+    }
+}
+test_affine3d_xrot();
+
+
+module test_affine3d_yrot() {
+    for(a = [-360:2/3:360]) {
+        assert(approx(affine3d_yrot(a), [[cos(a),0,sin(a),0],[0,1,0,0],[-sin(a),0,cos(a),0],[0,0,0,1]]));
+    }
+}
+test_affine3d_yrot();
+
+
+module test_affine3d_zrot() {
+    for(a = [-360:2/3:360]) {
+        assert(approx(affine3d_zrot(a), [[cos(a),-sin(a),0,0],[sin(a),cos(a),0,0],[0,0,1,0],[0,0,0,1]]));
+    }
+}
+test_affine3d_zrot();
+
+
+module test_affine3d_rot_by_axis() {
+    for(a = [-360:2/3:360]) {
+        assert(approx(affine3d_rot_by_axis(RIGHT,a), [[1,0,0,0],[0,cos(a),-sin(a),0],[0,sin(a),cos(a),0],[0,0,0,1]]));
+        assert(approx(affine3d_rot_by_axis(BACK,a), [[cos(a),0,sin(a),0],[0,1,0,0],[-sin(a),0,cos(a),0],[0,0,0,1]]));
+        assert(approx(affine3d_rot_by_axis(UP,a), [[cos(a),-sin(a),0,0],[sin(a),cos(a),0,0],[0,0,1,0],[0,0,0,1]]));
+    }
+}
+test_affine3d_rot_by_axis();
+
+
+module test_affine3d_rot_from_to() {
+    assert(approx(affine3d_rot_from_to(UP,FRONT), affine3d_xrot(90)));
+    assert(approx(affine3d_rot_from_to(UP,RIGHT), affine3d_yrot(90)));
+    assert(approx(affine3d_rot_from_to(BACK,LEFT), affine3d_zrot(90)));
+}
+test_affine3d_rot_from_to();
+
+
+module test_affine3d_skew() {
+    assert(affine3d_skew(sxy=2) == [[1,2,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]);
+    assert(affine3d_skew(sxz=2) == [[1,0,2,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]);
+    assert(affine3d_skew(syx=2) == [[1,0,0,0],[2,1,0,0],[0,0,1,0],[0,0,0,1]]);
+    assert(affine3d_skew(syz=2) == [[1,0,0,0],[0,1,2,0],[0,0,1,0],[0,0,0,1]]);
+    assert(affine3d_skew(szx=2) == [[1,0,0,0],[0,1,0,0],[2,0,1,0],[0,0,0,1]]);
+    assert(affine3d_skew(szy=2) == [[1,0,0,0],[0,1,0,0],[0,2,1,0],[0,0,0,1]]);
+}
+test_affine3d_skew();
+
+
+module test_affine3d_skew_xy() {
+    for(ya = [-89:3:89]) {
+        for(xa = [-89:3:89]) {
+            assert(affine3d_skew_xy(xa=xa, ya=ya) == [[1,0,tan(xa),0],[0,1,tan(ya),0],[0,0,1,0],[0,0,0,1]]);
+        }
+    }
+}
+test_affine3d_skew_xy();
+
+
+module test_affine3d_skew_xz() {
+    for(za = [-89:3:89]) {
+        for(xa = [-89:3:89]) {
+            assert(affine3d_skew_xz(xa=xa, za=za) == [[1,tan(xa),0,0],[0,1,0,0],[0,tan(za),1,0],[0,0,0,1]]);
+        }
+    }
+}
+test_affine3d_skew_xz();
+
+
+module test_affine3d_skew_yz() {
+    for(za = [-89:3:89]) {
+        for(ya = [-89:3:89]) {
+            assert(affine3d_skew_yz(ya=ya, za=za) == [[1,0,0,0],[tan(ya),1,0,0],[tan(za),0,1,0],[0,0,0,1]]);
+        }
+    }
+}
+test_affine3d_skew_yz();
+
+
+module test_affine3d_chain() {
+    t = affine3d_translate([15,30,23]);
+    s = affine3d_scale([1.5,2,1.8]);
+    r = affine3d_zrot(30);
+    assert(affine3d_chain([t,s,r]) == r * s * t);
+}
+test_affine3d_chain();
+
+
+////////////////////////////
+
+module test_affine_frame_map() {
+    assert(approx(affine_frame_map(x=[1,1,0], y=[-1,1,0]), affine3d_zrot(45)));
+}
+test_affine_frame_map();
+
+
+module test_apply() {
+    assert(approx(apply(affine3d_xrot(90),2*UP),2*FRONT));
+    assert(approx(apply(affine3d_yrot(90),2*UP),2*RIGHT));
+    assert(approx(apply(affine3d_zrot(90),2*UP),2*UP));
+    assert(approx(apply(affine3d_zrot(90),2*RIGHT),2*BACK));
+    assert(approx(apply(affine3d_zrot(90),2*BACK+2*RIGHT),2*BACK+2*LEFT));
+    assert(approx(apply(affine3d_xrot(135),2*BACK+2*UP),2*sqrt(2)*FWD));
+    assert(approx(apply(affine3d_yrot(135),2*RIGHT+2*UP),2*sqrt(2)*DOWN));
+    assert(approx(apply(affine3d_zrot(45),2*BACK+2*RIGHT),2*sqrt(2)*BACK));
+}
+test_apply();
+
+
+module test_apply_list() {
+    assert(approx(apply_list(25*(BACK+UP), []), 25*(BACK+UP)));
+    assert(approx(apply_list(25*(BACK+UP), [affine3d_xrot(135)]), 25*sqrt(2)*FWD));
+    assert(approx(apply_list(25*(RIGHT+UP), [affine3d_yrot(135)]), 25*sqrt(2)*DOWN));
+    assert(approx(apply_list(25*(BACK+RIGHT), [affine3d_zrot(45)]), 25*sqrt(2)*BACK));
+    assert(approx(apply_list(25*(BACK+UP), [affine3d_xrot(135), affine3d_translate([30,40,50])]), 25*sqrt(2)*FWD+[30,40,50]));
+    assert(approx(apply_list(25*(RIGHT+UP), [affine3d_yrot(135), affine3d_translate([30,40,50])]), 25*sqrt(2)*DOWN+[30,40,50]));
+    assert(approx(apply_list(25*(BACK+RIGHT), [affine3d_zrot(45), affine3d_translate([30,40,50])]), 25*sqrt(2)*BACK+[30,40,50]));
+}
+test_apply_list();
+
+
+
+// vim: expandtab tabstop=4 shiftwidth=4 softtabstop=4 nowrap
diff --git a/tests/test_vnf.scad b/tests/test_vnf.scad
index 353d117..65d5350 100644
--- a/tests/test_vnf.scad
+++ b/tests/test_vnf.scad
@@ -36,8 +36,8 @@ test_vnf_faces();
 
 module test_vnf_get_vertex() {
     vnf = [[[-1,-1,-1],[1,-1,-1],[0,1,-1],[0,0,1]],[[0,1,2],[0,3,1],[1,3,2],[2,3,0]]];
-    assert(vnf_get_vertex(vnf,[0,1,-1]) == [2,vnf]);
-    assert(vnf_get_vertex(vnf,[0,1,2]) == [4,[concat(vnf[0],[[0,1,2]]),vnf[1]]]);
+    assert(vnf_get_vertex(vnf,[0,1,-1]) == [[2],vnf]);
+    assert(vnf_get_vertex(vnf,[0,1,2]) == [[4],[concat(vnf[0],[[0,1,2]]),vnf[1]]]);
 }
 test_vnf_get_vertex();
 
diff --git a/version.scad b/version.scad
index efa1fb6..2012abe 100644
--- a/version.scad
+++ b/version.scad
@@ -8,7 +8,7 @@
 //////////////////////////////////////////////////////////////////////
 
 
-BOSL_VERSION = [2,0,354];
+BOSL_VERSION = [2,0,355];
 
 
 // Section: BOSL Library Version Functions