diff --git a/src/plots/polar/layout_attributes.js b/src/plots/polar/layout_attributes.js
index ba4d8fe25a6..841f984399a 100644
--- a/src/plots/polar/layout_attributes.js
+++ b/src/plots/polar/layout_attributes.js
@@ -122,11 +122,6 @@ var radialAxisAttrs = {
     // span: {},
     // hole: 1
 
-    // maybe should add a boolean to enable square grid lines
-    // and square axis lines
-    // (most common in radar-like charts)
-    // e.g. squareline/squaregrid or showline/showgrid: 'square' (on-top of true)
-
     editType: 'calc'
 };
 
@@ -272,6 +267,22 @@ module.exports = {
     radialaxis: radialAxisAttrs,
     angularaxis: angularAxisAttrs,
 
+    gridshape: {
+        valType: 'enumerated',
+        values: ['circular', 'linear'],
+        dflt: 'circular',
+        role: 'style',
+        editType: 'plot',
+        description: [
+            'Determines if the radial axis grid lines and angular axis line are drawn',
+            'as *circular* sectors or as *linear* (polygon) sectors.',
+            'Has an effect only when the angular axis has `type` *category*.',
+            'Note that `radialaxis.angle` is snapped to the angle of the closest',
+            'vertex when `gridshape` is *circular*',
+            '(so that radial axis scale is the same as the data scale).'
+        ].join(' ')
+    },
+
     // TODO maybe?
     // annotations:
 
diff --git a/src/plots/polar/layout_defaults.js b/src/plots/polar/layout_defaults.js
index ec383eafe69..19bf636326a 100644
--- a/src/plots/polar/layout_defaults.js
+++ b/src/plots/polar/layout_defaults.js
@@ -177,6 +177,10 @@ function handleDefaults(contIn, contOut, coerce, opts) {
 
         axOut._input = axIn;
     }
+
+    if(contOut.angularaxis.type === 'category') {
+        coerce('gridshape');
+    }
 }
 
 function handleAxisTypeDefaults(axIn, axOut, coerce, subplotData, dataAttr) {
diff --git a/src/plots/polar/polar.js b/src/plots/polar/polar.js
index 5b8264194a3..3a91414d45b 100644
--- a/src/plots/polar/polar.js
+++ b/src/plots/polar/polar.js
@@ -25,6 +25,7 @@ var Titles = require('../../components/titles');
 var prepSelect = require('../cartesian/select').prepSelect;
 var clearSelect = require('../cartesian/select').clearSelect;
 var setCursor = require('../../lib/setcursor');
+var polygonTester = require('../../lib/polygon').tester;
 
 var MID_SHIFT = require('../../constants/alignment').MID_SHIFT;
 
@@ -42,6 +43,8 @@ function Polar(gd, id) {
     this.gd = gd;
 
     this._hasClipOnAxisFalse = null;
+    this.vangles = null;
+    this.radialAxisAngle = null;
     this.traceHash = {};
     this.layers = {};
     this.clipPaths = {};
@@ -51,10 +54,10 @@ function Polar(gd, id) {
     var fullLayout = gd._fullLayout;
     var clipIdBase = 'clip' + fullLayout._uid + id;
 
-    this.clipIds.circle = clipIdBase + '-circle';
-    this.clipPaths.circle = fullLayout._clips.append('clipPath')
-        .attr('id', this.clipIds.circle);
-    this.clipPaths.circle.append('path');
+    this.clipIds.forTraces = clipIdBase + '-for-traces';
+    this.clipPaths.forTraces = fullLayout._clips.append('clipPath')
+        .attr('id', this.clipIds.forTraces);
+    this.clipPaths.forTraces.append('path');
 
     this.framework = fullLayout._polarlayer.append('g')
         .attr('class', id);
@@ -130,7 +133,7 @@ proto.updateLayers = function(fullLayout, polarLayout) {
                     sel.append('g').classed('maplayer', true);
                     break;
                 case 'plotbg':
-                    layers.bgcircle = sel.append('path');
+                    layers.bg = sel.append('path');
                     break;
                 case 'radial-grid':
                     sel.style('fill', 'none');
@@ -207,9 +210,50 @@ proto.updateLayout = function(fullLayout, polarLayout) {
     var cxx = _this.cxx = cx - xOffset2;
     var cyy = _this.cyy = cy - yOffset2;
 
+    var mockOpts = {
+        // to get _boundingBox computation right when showticklabels is false
+        anchor: 'free',
+        position: 0,
+        // dummy truthy value to make Axes.doTicksSingle draw the grid
+        _counteraxis: true,
+        // don't use automargins routine for labels
+        automargin: false
+    };
+
+    _this.radialAxis = Lib.extendFlat({}, polarLayout.radialaxis, mockOpts, {
+        _axislayer: layers['radial-axis'],
+        _gridlayer: layers['radial-grid'],
+        // make this an 'x' axis to make positioning (especially rotation) easier
+        _id: 'x',
+        _pos: 0,
+        // convert to 'x' axis equivalent
+        side: {
+            counterclockwise: 'top',
+            clockwise: 'bottom'
+        }[polarLayout.radialaxis.side],
+        // spans length 1 radius
+        domain: [0, radius / gs.w]
+    });
+
+    _this.angularAxis = Lib.extendFlat({}, polarLayout.angularaxis, mockOpts, {
+        _axislayer: layers['angular-axis'],
+        _gridlayer: layers['angular-grid'],
+        // angular axes need *special* logic
+        _id: 'angular',
+        _pos: 0,
+        side: 'right',
+        // to get auto nticks right
+        domain: [0, Math.PI],
+        // don't pass through autorange logic
+        autorange: false
+    });
+
+    _this.doAutoRange(fullLayout, polarLayout);
+    // N.B. this sets _this.vangles
+    _this.updateAngularAxis(fullLayout, polarLayout);
+    // N.B. this sets _this.radialAxisAngle
     _this.updateRadialAxis(fullLayout, polarLayout);
     _this.updateRadialAxisTitle(fullLayout, polarLayout);
-    _this.updateAngularAxis(fullLayout, polarLayout);
 
     var radialRange = _this.radialAxis.range;
     var rSpan = radialRange[1] - radialRange[0];
@@ -235,25 +279,35 @@ proto.updateLayout = function(fullLayout, polarLayout) {
     xaxis.isPtWithinRange = function(d) { return _this.isPtWithinSector(d); };
     yaxis.isPtWithinRange = function() { return true; };
 
+    _this.clipPaths.forTraces.select('path')
+        .attr('d', pathSectorClosed(radius, sector, _this.vangles))
+        .attr('transform', strTranslate(cxx, cyy));
+
     layers.frontplot
         .attr('transform', strTranslate(xOffset2, yOffset2))
-        .call(Drawing.setClipUrl, _this._hasClipOnAxisFalse ? null : _this.clipIds.circle);
+        .call(Drawing.setClipUrl, _this._hasClipOnAxisFalse ? null : _this.clipIds.forTraces);
 
-    layers.bgcircle.attr({
-        d: pathSectorClosed(radius, sector),
-        transform: strTranslate(cx, cy)
-    })
-    .call(Color.fill, polarLayout.bgcolor);
-
-    _this.clipPaths.circle.select('path')
-        .attr('d', pathSectorClosed(radius, sector))
-        .attr('transform', strTranslate(cxx, cyy));
+    layers.bg
+        .attr('d', pathSectorClosed(radius, sector, _this.vangles))
+        .attr('transform', strTranslate(cx, cy))
+        .call(Color.fill, polarLayout.bgcolor);
 
     // remove crispEdges - all the off-square angles in polar plots
     // make these counterproductive.
     _this.framework.selectAll('.crisp').classed('crisp', 0);
 };
 
+proto.doAutoRange = function(fullLayout, polarLayout) {
+    var radialLayout = polarLayout.radialaxis;
+    var ax = this.radialAxis;
+
+    setScale(ax, radialLayout, fullLayout);
+    doAutoRange(ax);
+
+    radialLayout.range = ax.range.slice();
+    radialLayout._input.range = ax.range.slice();
+};
+
 proto.updateRadialAxis = function(fullLayout, polarLayout) {
     var _this = this;
     var gd = _this.gd;
@@ -261,42 +315,12 @@ proto.updateRadialAxis = function(fullLayout, polarLayout) {
     var radius = _this.radius;
     var cx = _this.cx;
     var cy = _this.cy;
-    var gs = fullLayout._size;
     var radialLayout = polarLayout.radialaxis;
     var sector = polarLayout.sector;
     var a0 = wrap360(sector[0]);
+    var ax = _this.radialAxis;
 
     _this.fillViewInitialKey('radialaxis.angle', radialLayout.angle);
-
-    var ax = _this.radialAxis = Lib.extendFlat({}, radialLayout, {
-        _axislayer: layers['radial-axis'],
-        _gridlayer: layers['radial-grid'],
-
-        // make this an 'x' axis to make positioning (especially rotation) easier
-        _id: 'x',
-        _pos: 0,
-
-        // convert to 'x' axis equivalent
-        side: {counterclockwise: 'top', clockwise: 'bottom'}[radialLayout.side],
-
-        // spans length 1 radius
-        domain: [0, radius / gs.w],
-
-        // to get _boundingBox computation right when showticklabels is false
-        anchor: 'free',
-        position: 0,
-
-        // dummy truthy value to make Axes.doTicksSingle draw the grid
-        _counteraxis: true,
-
-        // don't use automargins routine for labels
-        automargin: false
-    });
-
-    setScale(ax, radialLayout, fullLayout);
-    doAutoRange(ax);
-    radialLayout.range = ax.range.slice();
-    radialLayout._input.range = ax.range.slice();
     _this.fillViewInitialKey('radialaxis.range', ax.range.slice());
 
     // rotate auto tick labels by 180 if in quadrant II and III to make them
@@ -315,7 +339,7 @@ proto.updateRadialAxis = function(fullLayout, polarLayout) {
     // set special grid path function
     ax._gridpath = function(d) {
         var r = ax.r2p(d.x);
-        return pathSector(r, sector);
+        return pathSector(r, sector, _this.vangles);
     };
 
     var newTickLayout = strTickLayout(radialLayout);
@@ -326,8 +350,15 @@ proto.updateRadialAxis = function(fullLayout, polarLayout) {
 
     Axes.doTicksSingle(gd, ax, true);
 
+    // stash 'actual' radial axis angle for drag handlers (in degrees)
+    var angle = _this.radialAxisAngle = _this.vangles ?
+        rad2deg(snapToVertexAngle(deg2rad(radialLayout.angle), _this.vangles)) :
+        radialLayout.angle;
+
+    var trans = strTranslate(cx, cy) + strRotate(-angle);
+
     updateElement(layers['radial-axis'], radialLayout.showticklabels || radialLayout.ticks, {
-        transform: strTranslate(cx, cy) + strRotate(-radialLayout.angle)
+        transform: trans
     });
 
     // move all grid paths to about circle center,
@@ -342,7 +373,7 @@ proto.updateRadialAxis = function(fullLayout, polarLayout) {
         y1: 0,
         x2: radius,
         y2: 0,
-        transform: strTranslate(cx, cy) + strRotate(-radialLayout.angle)
+        transform: trans
     })
     .attr('stroke-width', radialLayout.linewidth)
     .call(Color.stroke, radialLayout.linecolor);
@@ -357,7 +388,7 @@ proto.updateRadialAxisTitle = function(fullLayout, polarLayout, _angle) {
     var radialLayout = polarLayout.radialaxis;
     var titleClass = _this.id + 'title';
 
-    var angle = _angle !== undefined ? _angle : radialLayout.angle;
+    var angle = _angle !== undefined ? _angle : _this.radialAxisAngle;
     var angleRad = deg2rad(angle);
     var cosa = Math.cos(angleRad);
     var sina = Math.sin(angleRad);
@@ -394,34 +425,20 @@ proto.updateAngularAxis = function(fullLayout, polarLayout) {
     var angularLayout = polarLayout.angularaxis;
     var sector = polarLayout.sector;
     var sectorInRad = sector.map(deg2rad);
+    var ax = _this.angularAxis;
 
     _this.fillViewInitialKey('angularaxis.rotation', angularLayout.rotation);
 
-    var ax = _this.angularAxis = Lib.extendFlat({}, angularLayout, {
-        _axislayer: layers['angular-axis'],
-        _gridlayer: layers['angular-grid'],
-
-        // angular axes need *special* logic
-        _id: 'angular',
-        _pos: 0,
-        side: 'right',
-
-        // to get auto nticks right
-        domain: [0, Math.PI],
-
-        // to get _boundingBox computation right when showticklabels is false
-        anchor: 'free',
-        position: 0,
-
-        // dummy truthy value to make Axes.doTicksSingle draw the grid
-        _counteraxis: true,
-
-        // don't use automargins routine for labels
-        automargin: false,
+    // wrapper around c2rad from setConvertAngular
+    // note that linear ranges are always set in degrees for Axes.doTicksSingle
+    function c2rad(d) {
+        return ax.c2rad(d.x, 'degrees');
+    }
 
-        // don't pass through autorange logic
-        autorange: false
-    });
+    // (x,y) at max radius
+    function rad2xy(rad) {
+        return [radius * Math.cos(rad), radius * Math.sin(rad)];
+    }
 
     // Set the angular range in degrees to make auto-tick computation cleaner,
     // changing rotation/direction should not affect the angular tick labels.
@@ -448,28 +465,11 @@ proto.updateAngularAxis = function(fullLayout, polarLayout) {
             angularLayout._categories.length;
 
         ax.range = [0, period];
-
-        ax._tickFilter = function(d) {
-            return _this.isPtWithinSector({
-                r: _this.radialAxis.range[1],
-                rad: ax.c2rad(d.x)
-            });
-        };
+        ax._tickFilter = function(d) { return isAngleInSector(c2rad(d), sector); };
     }
 
     setScale(ax, angularLayout, fullLayout);
 
-    // wrapper around c2rad from setConvertAngular
-    // note that linear ranges are always set in degrees for Axes.doTicksSingle
-    function c2rad(d) {
-        return ax.c2rad(d.x, 'degrees');
-    }
-
-    // (x,y) at max radius
-    function rad2xy(rad) {
-        return [radius * Math.cos(rad), radius * Math.sin(rad)];
-    }
-
     ax._transfn = function(d) {
         var rad = c2rad(d);
         var xy = rad2xy(rad);
@@ -532,8 +532,24 @@ proto.updateAngularAxis = function(fullLayout, polarLayout) {
 
     Axes.doTicksSingle(gd, ax, true);
 
+    // angle of polygon vertices in radians (null means circles)
+    // TODO what to do when ax.period > ax._categories ??
+    var vangles;
+    if(polarLayout.gridshape === 'linear') {
+        vangles = ax._vals.map(c2rad);
+
+        // ax._vals should be always ordered, make them
+        // always turn counterclockwise for convenience here
+        if(angleDelta(vangles[0], vangles[1]) < 0) {
+            vangles = vangles.slice().reverse();
+        }
+    } else {
+        vangles = null;
+    }
+    _this.vangles = vangles;
+
     updateElement(layers['angular-line'].select('path'), angularLayout.showline, {
-        d: pathSectorClosed(radius, sector),
+        d: pathSectorClosed(radius, sector, vangles),
         transform: strTranslate(cx, cy)
     })
     .attr('stroke-width', angularLayout.linewidth)
@@ -561,11 +577,14 @@ proto.updateMainDrag = function(fullLayout, polarLayout) {
     var cxx = _this.cxx;
     var cyy = _this.cyy;
     var sector = polarLayout.sector;
+    var vangles = _this.vangles;
+    var chw = constants.cornerHalfWidth;
+    var chl = constants.cornerLen / 2;
 
     var mainDrag = dragBox.makeDragger(layers, 'path', 'maindrag', 'crosshair');
 
     d3.select(mainDrag)
-        .attr('d', pathSectorClosed(radius, sector))
+        .attr('d', pathSectorClosed(radius, sector, vangles))
         .attr('transform', strTranslate(cx, cy));
 
     var dragOpts = {
@@ -589,10 +608,12 @@ proto.updateMainDrag = function(fullLayout, polarLayout) {
     // zoombox, corners elements
     var zb, corners;
 
+    function norm(x, y) {
+        return Math.sqrt(x * x + y * y);
+    }
+
     function xy2r(x, y) {
-        var xx = x - cxx;
-        var yy = y - cyy;
-        return Math.sqrt(xx * xx + yy * yy);
+        return norm(x - cxx, y - cyy);
     }
 
     function xy2a(x, y) {
@@ -603,13 +624,14 @@ proto.updateMainDrag = function(fullLayout, polarLayout) {
         return [r * Math.cos(a), r * Math.sin(-a)];
     }
 
-    function pathCorner(r, a) {
-        var clen = constants.cornerLen;
-        var chw = constants.cornerHalfWidth;
+    function _pathSectorClosed(r) {
+        return pathSectorClosed(r, sector, vangles);
+    }
 
-        if(r === 0) return pathSectorClosed(2 * chw, sector);
+    function pathCorner(r, a) {
+        if(r === 0) return _pathSectorClosed(2 * chw);
 
-        var da = clen / r / 2;
+        var da = chl / r;
         var am = a - da;
         var ap = a + da;
         var rb = Math.max(0, Math.min(r, radius));
@@ -623,10 +645,48 @@ proto.updateMainDrag = function(fullLayout, polarLayout) {
             'Z';
     }
 
+    // (x,y) is the pt at middle of the va0 <-> va1 edge
+    //
+    // ... we could eventually add another mode for cursor
+    // angles 'close to' enough to a particular vertex.
+    function pathCornerForPolygons(r, va0, va1) {
+        if(r === 0) return _pathSectorClosed(2 * chw);
+
+        var xy0 = ra2xy(r, va0);
+        var xy1 = ra2xy(r, va1);
+        var x = clampTiny((xy0[0] + xy1[0]) / 2);
+        var y = clampTiny((xy0[1] + xy1[1]) / 2);
+        var innerPts, outerPts;
+
+        if(x && y) {
+            var m = y / x;
+            var mperp = -1 / m;
+            var midPts = findXYatLength(chw, m, x, y);
+            innerPts = findXYatLength(chl, mperp, midPts[0][0], midPts[0][1]);
+            outerPts = findXYatLength(chl, mperp, midPts[1][0], midPts[1][1]);
+        } else {
+            var dx, dy;
+            if(y) {
+                // horizontal handles
+                dx = chl;
+                dy = chw;
+            } else {
+                // vertical handles
+                dx = chw;
+                dy = chl;
+            }
+            innerPts = [[x - dx, y - dy], [x + dx, y - dy]];
+            outerPts = [[x - dx, y + dy], [x + dx, y + dy]];
+        }
+
+        return 'M' + innerPts.join('L') +
+            'L' + outerPts.reverse().join('L') + 'Z';
+    }
+
     function zoomPrep() {
         r0 = null;
         r1 = null;
-        path0 = pathSectorClosed(radius, sector);
+        path0 = _pathSectorClosed(radius);
         dimmed = false;
 
         var polarLayoutNow = gd._fullLayout[_this.id];
@@ -638,13 +698,10 @@ proto.updateMainDrag = function(fullLayout, polarLayout) {
         clearSelect(zoomlayer);
     }
 
-    function zoomMove(dx, dy) {
-        var x1 = x0 + dx;
-        var y1 = y0 + dy;
-        var rr0 = xy2r(x0, y0);
-        var rr1 = Math.min(xy2r(x1, y1), radius);
-        var a0 = xy2a(x0, y0);
-        var a1 = xy2a(x1, y1);
+    // N.B. this sets scoped 'r0' and 'r1'
+    // return true if 'valid' zoom distance, false otherwise
+    function clampAndSetR0R1(rr0, rr1) {
+        rr1 = Math.min(rr1, radius);
 
         // starting or ending drag near center (outer edge),
         // clamps radial distance at origin (at r=radius)
@@ -653,29 +710,27 @@ proto.updateMainDrag = function(fullLayout, polarLayout) {
         else if(rr1 < OFFEDGE) rr1 = 0;
         else if((radius - rr1) < OFFEDGE) rr1 = radius;
 
-        var path1;
-        var cpath;
-
+        // make sure r0 < r1,
+        // to get correct fill pattern in path1 below
         if(Math.abs(rr1 - rr0) > MINZOOM) {
-            // make sure r0 < r1,
-            // to get correct fill pattern in path1 below
             if(rr0 < rr1) {
                 r0 = rr0;
                 r1 = rr1;
             } else {
                 r0 = rr1;
                 r1 = rr0;
-                a1 = [a0, a0 = a1][0]; // swap a0 and a1
             }
-
-            path1 = path0 + pathSectorClosed(r1, sector) + pathSectorClosed(r0, sector);
-            cpath = pathCorner(r0, a0) + pathCorner(r1, a1);
+            return true;
         } else {
             r0 = null;
             r1 = null;
-            path1 = path0;
-            cpath = 'M0,0Z';
+            return false;
         }
+    }
+
+    function applyZoomMove(path1, cpath) {
+        path1 = path1 || path0;
+        cpath = cpath || 'M0,0Z';
 
         zb.attr('d', path1);
         corners.attr('d', cpath);
@@ -683,6 +738,60 @@ proto.updateMainDrag = function(fullLayout, polarLayout) {
         dimmed = true;
     }
 
+    function zoomMove(dx, dy) {
+        var x1 = x0 + dx;
+        var y1 = y0 + dy;
+        var rr0 = xy2r(x0, y0);
+        var rr1 = Math.min(xy2r(x1, y1), radius);
+        var a0 = xy2a(x0, y0);
+        var path1;
+        var cpath;
+
+        if(clampAndSetR0R1(rr0, rr1)) {
+            path1 = path0 + _pathSectorClosed(r1) + _pathSectorClosed(r0);
+            // keep 'starting' angle
+            cpath = pathCorner(r0, a0) + pathCorner(r1, a0);
+        }
+        applyZoomMove(path1, cpath);
+    }
+
+    function findEnclosingVertexAngles(a) {
+        var i0 = findIndexOfMin(vangles, function(v) {
+            var adelta = angleDelta(v, a);
+            return adelta > 0 ? adelta : Infinity;
+        });
+        var i1 = Lib.mod(i0 + 1, vangles.length);
+        return [vangles[i0], vangles[i1]];
+    }
+
+    function findPolygonRadius(x, y, va0, va1) {
+        var xy = findIntersectionXY(va0, va1, va0, [x - cxx, cyy - y]);
+        return norm(xy[0], xy[1]);
+    }
+
+    function zoomMoveForPolygons(dx, dy) {
+        var x1 = x0 + dx;
+        var y1 = y0 + dy;
+        var a0 = xy2a(x0, y0);
+        var a1 = xy2a(x1, y1);
+        var vangles0 = findEnclosingVertexAngles(a0);
+        var vangles1 = findEnclosingVertexAngles(a1);
+        var rr0 = findPolygonRadius(x0, y0, vangles0[0], vangles0[1]);
+        var rr1 = Math.min(findPolygonRadius(x1, y1, vangles1[0], vangles1[1]), radius);
+        var path1;
+        var cpath;
+
+        if(clampAndSetR0R1(rr0, rr1)) {
+            path1 = path0 + _pathSectorClosed(r1) + _pathSectorClosed(r0);
+            // keep 'starting' angle here too
+            cpath = [
+                pathCornerForPolygons(r0, vangles0[0], vangles0[1]),
+                pathCornerForPolygons(r1, vangles0[0], vangles0[1])
+            ].join(' ');
+        }
+        applyZoomMove(path1, cpath);
+    }
+
     function zoomDone() {
         dragBox.removeZoombox(gd);
 
@@ -709,9 +818,21 @@ proto.updateMainDrag = function(fullLayout, polarLayout) {
         x0 = startX - bbox.left;
         y0 = startY - bbox.top;
 
+        // need to offset x/y as bbox center does not
+        // match origin for asymmetric polygons
+        if(vangles) {
+            var offset = findPolygonOffset(radius, sector, vangles);
+            x0 += cxx + offset[0];
+            y0 += cyy + offset[1];
+        }
+
         switch(dragModeNow) {
             case 'zoom':
-                dragOpts.moveFn = zoomMove;
+                if(vangles) {
+                    dragOpts.moveFn = zoomMoveForPolygons;
+                } else {
+                    dragOpts.moveFn = zoomMove;
+                }
                 dragOpts.doneFn = zoomDone;
                 zoomPrep(evt, startX, startY);
                 break;
@@ -762,7 +883,7 @@ proto.updateRadialDrag = function(fullLayout, polarLayout) {
     var cy = _this.cy;
     var radialAxis = _this.radialAxis;
     var radialLayout = polarLayout.radialaxis;
-    var angle0 = deg2rad(radialLayout.angle);
+    var angle0 = deg2rad(_this.radialAxisAngle);
     var range0 = radialAxis.range.slice();
     var drange = range0[1] - range0[0];
     var bl = constants.radialDragBoxSize;
@@ -813,7 +934,9 @@ proto.updateRadialDrag = function(fullLayout, polarLayout) {
         var x1 = tx + dx;
         var y1 = ty + dy;
 
-        angle1 = rad2deg(Math.atan2(cy - y1, x1 - cx));
+        angle1 = Math.atan2(cy - y1, x1 - cx);
+        if(_this.vangles) angle1 = snapToVertexAngle(angle1, _this.vangles);
+        angle1 = rad2deg(angle1);
 
         var transform = strTranslate(cx, cy) + strRotate(-angle1);
         layers['radial-axis'].attr('transform', transform);
@@ -897,9 +1020,19 @@ proto.updateAngularDrag = function(fullLayout, polarLayout) {
 
     var angularDrag = dragBox.makeDragger(layers, 'path', 'angulardrag', 'move');
     var dragOpts = {element: angularDrag, gd: gd};
+    var angularDragPath;
+
+    if(_this.vangles) {
+        // use evenodd svg rule
+        var outer = invertY(makePolygon(radius + dbs, sector, _this.vangles));
+        var inner = invertY(makePolygon(radius, sector, _this.vangles));
+        angularDragPath = 'M' + outer.reverse().join('L') + 'M' + inner.join('L');
+    } else {
+        angularDragPath = pathAnnulus(radius, radius + dbs, sector);
+    }
 
     d3.select(angularDrag)
-        .attr('d', pathAnnulus(radius, radius + dbs, sector))
+        .attr('d', angularDragPath)
         .attr('transform', strTranslate(cx, cy))
         .call(setCursor, 'move');
 
@@ -916,12 +1049,16 @@ proto.updateAngularDrag = function(fullLayout, polarLayout) {
     var x0, y0;
     // angular axis angle rotation at drag start (0), move (1)
     var rot0, rot1;
+    // induced radial axis rotation (only used on polygon grids)
+    var rrot1;
     // copy of polar sector value at drag start
     var sector0;
     // angle about circle center at drag start
     var a0;
 
     function moveFn(dx, dy) {
+        var fullLayoutNow = _this.gd._fullLayout;
+        var polarLayoutNow = fullLayoutNow[_this.id];
         var x1 = x0 + dx;
         var y1 = y0 + dy;
         var a1 = xy2a(x1, y1);
@@ -932,9 +1069,23 @@ proto.updateAngularDrag = function(fullLayout, polarLayout) {
             strTranslate(_this.xOffset2, _this.yOffset2) + strRotate([-da, cxx, cyy])
         );
 
-        _this.clipPaths.circle.select('path').attr('transform',
-            strTranslate(cxx, cyy) + strRotate(da)
-        );
+        if(_this.vangles) {
+            rrot1 = _this.radialAxisAngle + da;
+
+            var trans = strTranslate(cx, cy) + strRotate(-da);
+            var trans2 = strTranslate(cx, cy) + strRotate(-rrot1);
+
+            layers.bg.attr('transform', trans);
+            layers['radial-grid'].attr('transform', trans);
+            layers['angular-line'].select('path').attr('transform', trans);
+            layers['radial-axis'].attr('transform', trans2);
+            layers['radial-line'].select('line').attr('transform', trans2);
+            _this.updateRadialAxisTitle(fullLayoutNow, polarLayoutNow, rrot1);
+        } else {
+            _this.clipPaths.forTraces.select('path').attr('transform',
+                strTranslate(cxx, cyy) + strRotate(da)
+            );
+        }
 
         // 'un-rotate' marker and text points
         scatterPoints.each(function() {
@@ -974,8 +1125,6 @@ proto.updateAngularDrag = function(fullLayout, polarLayout) {
                 var moduleCalcData = _this.traceHash[k];
                 var moduleCalcDataVisible = Lib.filterVisible(moduleCalcData);
                 var _module = moduleCalcData[0][0].trace._module;
-                var polarLayoutNow = gd._fullLayout[_this.id];
-
                 _module.plot(gd, _this, moduleCalcDataVisible, polarLayoutNow);
             }
         }
@@ -983,8 +1132,14 @@ proto.updateAngularDrag = function(fullLayout, polarLayout) {
 
     function doneFn() {
         scatterTextPoints.select('text').attr('transform', null);
+
         var updateObj = {};
         updateObj[_this.id + '.angularaxis.rotation'] = rot1;
+
+        if(_this.vangles) {
+            updateObj[_this.id + '.radialaxis.angle'] = rrot1;
+        }
+
         Registry.call('relayout', gd, updateObj);
     }
 
@@ -1004,22 +1159,27 @@ proto.updateAngularDrag = function(fullLayout, polarLayout) {
         clearSelect(fullLayout._zoomlayer);
     };
 
+    // I don't what we should do in this case, skip we now
+    if(_this.vangles && !isFullCircle(sector)) {
+        dragOpts.prepFn = Lib.noop;
+        setCursor(d3.select(angularDrag), null);
+    }
+
     dragElement.init(dragOpts);
 };
 
 proto.isPtWithinSector = function(d) {
     var sector = this.sector;
+
+    if(!isAngleInSector(d.rad, sector)) {
+        return false;
+    }
+
+    var vangles = this.vangles;
     var radialAxis = this.radialAxis;
     var radialRange = radialAxis.range;
     var r = radialAxis.c2r(d.r);
 
-    var s0 = wrap360(sector[0]);
-    var s1 = wrap360(sector[1]);
-    if(s0 > s1) s1 += 360;
-
-    var deg = wrap360(rad2deg(d.rad));
-    var nextTurnDeg = deg + 360;
-
     var r0, r1;
     if(radialRange[1] >= radialRange[0]) {
         r0 = radialRange[0];
@@ -1029,13 +1189,14 @@ proto.isPtWithinSector = function(d) {
         r1 = radialRange[0];
     }
 
-    return (
-        (r >= r0 && r <= r1) &&
-        (isFullCircle(sector) ||
-            (deg >= s0 && deg <= s1) ||
-            (nextTurnDeg >= s0 && nextTurnDeg <= s1)
-        )
-    );
+    if(vangles) {
+        var polygonIn = polygonTester(makePolygon(r0, sector, vangles));
+        var polygonOut = polygonTester(makePolygon(r1, sector, vangles));
+        var xy = [r * Math.cos(d.rad), r * Math.sin(d.rad)];
+        return polygonOut.contains(xy) && !polygonIn.contains(xy);
+    }
+
+    return r >= r0 && r <= r1;
 };
 
 proto.fillViewInitialKey = function(key, val) {
@@ -1116,26 +1277,236 @@ function computeSectorBBox(sector) {
     return [x0, y0, x1, y1];
 }
 
-function pathSector(r, sector) {
-    if(isFullCircle(sector)) {
-        return Drawing.symbolFuncs[0](r);
+function isAngleInSector(rad, sector) {
+    if(isFullCircle(sector)) return true;
+
+    var s0 = wrap360(sector[0]);
+    var s1 = wrap360(sector[1]);
+    if(s0 > s1) s1 += 360;
+
+    var deg = wrap360(rad2deg(rad));
+    var nextTurnDeg = deg + 360;
+
+    return (deg >= s0 && deg <= s1) ||
+        (nextTurnDeg >= s0 && nextTurnDeg <= s1);
+}
+
+function snapToVertexAngle(a, vangles) {
+    function angleDeltaAbs(va) {
+        return Math.abs(angleDelta(a, va));
     }
 
-    var xs = r * Math.cos(deg2rad(sector[0]));
-    var ys = -r * Math.sin(deg2rad(sector[0]));
-    var xe = r * Math.cos(deg2rad(sector[1]));
-    var ye = -r * Math.sin(deg2rad(sector[1]));
+    var ind = findIndexOfMin(vangles, angleDeltaAbs);
+    return vangles[ind];
+}
 
-    var arc = Math.abs(sector[1] - sector[0]);
-    var flags = arc <= 180 ? [0, 0, 0] : [0, 1, 0];
+// taken from https://stackoverflow.com/a/2007279
+function angleDelta(a, b) {
+    var d = b - a;
+    return Math.atan2(Math.sin(d), Math.cos(d));
+}
+
+function findIndexOfMin(arr, fn) {
+    fn = fn || Lib.identity;
+
+    var min = Infinity;
+    var ind;
+
+    for(var i = 0; i < arr.length; i++) {
+        var v = fn(arr[i]);
+        if(v < min) {
+            min = v;
+            ind = i;
+        }
+    }
+    return ind;
+}
+
+// find intersection of 'v0' <-> 'v1' edge with a ray at angle 'a'
+// (i.e. a line that starts from the origin at angle 'a')
+// given an (xp,yp) pair on the 'v0' <-> 'v1' line
+// (N.B. 'v0' and 'v1' are angles in radians)
+function findIntersectionXY(v0, v1, a, xpyp) {
+    var xstar, ystar;
+
+    var xp = xpyp[0];
+    var yp = xpyp[1];
+    var dsin = clampTiny(Math.sin(v1) - Math.sin(v0));
+    var dcos = clampTiny(Math.cos(v1) - Math.cos(v0));
+    var tanA = Math.tan(a);
+    var cotanA = clampTiny(1 / tanA);
+    var m = dsin / dcos;
+    var b = yp - m * xp;
+
+    if(cotanA) {
+        if(dsin && dcos) {
+            // given
+            //  g(x) := v0 -> v1 line = m*x + b
+            //  h(x) := ray at angle 'a' = m*x = tanA*x
+            // solve g(xstar) = h(xstar)
+            xstar = b / (tanA - m);
+            ystar = tanA * xstar;
+        } else if(dcos) {
+            // horizontal v0 -> v1
+            xstar = yp * cotanA;
+            ystar = yp;
+        } else {
+            // vertical v0 -> v1
+            xstar = xp;
+            ystar = xp * tanA;
+        }
+    } else {
+        // vertical ray
+        if(dsin && dcos) {
+            xstar = 0;
+            ystar = b;
+        } else if(dcos) {
+            xstar = 0;
+            ystar = yp;
+        } else {
+            // does this case exists?
+            xstar = ystar = NaN;
+        }
+    }
+
+    return [xstar, ystar];
+}
+
+// solves l^2 = (f(x)^2 - yp)^2 + (x - xp)^2
+// rearranged into 0 = a*x^2 + b * x + c
+//
+// where f(x) = m*x + t + yp
+// and   (x0, x1) = (-b +/- del) / (2*a)
+function findXYatLength(l, m, xp, yp) {
+    var t = -m * xp;
+    var a = m * m + 1;
+    var b = 2 * (m * t - xp);
+    var c = t * t + xp * xp - l * l;
+    var del = Math.sqrt(b * b - 4 * a * c);
+    var x0 = (-b + del) / (2 * a);
+    var x1 = (-b - del) / (2 * a);
+    return [
+        [x0, m * x0 + t + yp],
+        [x1, m * x1 + t + yp]
+    ];
+}
 
-    return 'M' + [xs, ys] +
-        'A' + [r, r] + ' ' + flags + ' ' + [xe, ye];
+function makeRegularPolygon(r, vangles) {
+    var len = vangles.length;
+    var vertices = new Array(len + 1);
+    var i;
+    for(i = 0; i < len; i++) {
+        var va = vangles[i];
+        vertices[i] = [r * Math.cos(va), r * Math.sin(va)];
+    }
+    vertices[i] = vertices[0].slice();
+    return vertices;
+}
+
+function makeClippedPolygon(r, sector, vangles) {
+    var len = vangles.length;
+    var vertices = [];
+    var i, j;
+
+    function a2xy(a) {
+        return [r * Math.cos(a), r * Math.sin(a)];
+    }
+
+    function findXY(va0, va1, s) {
+        return findIntersectionXY(va0, va1, s, a2xy(va0));
+    }
+
+    function cycleIndex(ind) {
+        return Lib.mod(ind, len);
+    }
+
+    var s0 = deg2rad(sector[0]);
+    var s1 = deg2rad(sector[1]);
+
+    // find index in sector closest to sector[0],
+    // use it to find intersection of v[i0] <-> v[i0-1] edge with sector radius
+    var i0 = findIndexOfMin(vangles, function(v) {
+        return isAngleInSector(v, sector) ? Math.abs(angleDelta(v, s0)) : Infinity;
+    });
+    var xy0 = findXY(vangles[i0], vangles[cycleIndex(i0 - 1)], s0);
+    vertices.push(xy0);
+
+    // fill in in-sector vertices
+    for(i = i0, j = 0; j < len; i++, j++) {
+        var va = vangles[cycleIndex(i)];
+        if(!isAngleInSector(va, sector)) break;
+        vertices.push(a2xy(va));
+    }
+
+    // find index in sector closest to sector[1],
+    // use it to find intersection of v[iN] <-> v[iN+1] edge with sector radius
+    var iN = findIndexOfMin(vangles, function(v) {
+        return isAngleInSector(v, sector) ? Math.abs(angleDelta(v, s1)) : Infinity;
+    });
+    var xyN = findXY(vangles[iN], vangles[cycleIndex(iN + 1)], s1);
+    vertices.push(xyN);
+
+    vertices.push([0, 0]);
+    vertices.push(vertices[0].slice());
+
+    return vertices;
 }
 
-function pathSectorClosed(r, sector) {
-    return pathSector(r, sector) +
-        (isFullCircle(sector) ? '' : 'L0,0Z');
+function makePolygon(r, sector, vangles) {
+    return isFullCircle(sector) ?
+        makeRegularPolygon(r, vangles) :
+        makeClippedPolygon(r, sector, vangles);
+}
+
+function findPolygonOffset(r, sector, vangles) {
+    var minX = Infinity;
+    var minY = Infinity;
+    var vertices = makePolygon(r, sector, vangles);
+
+    for(var i = 0; i < vertices.length; i++) {
+        var v = vertices[i];
+        minX = Math.min(minX, v[0]);
+        minY = Math.min(minY, -v[1]);
+    }
+    return [minX, minY];
+}
+
+function invertY(pts0) {
+    var len = pts0.length;
+    var pts1 = new Array(len);
+    for(var i = 0; i < len; i++) {
+        var pt = pts0[i];
+        pts1[i] = [pt[0], -pt[1]];
+    }
+    return pts1;
+}
+
+function pathSector(r, sector, vangles) {
+    var d;
+
+    if(vangles) {
+        d = 'M' + invertY(makePolygon(r, sector, vangles)).join('L');
+    } else if(isFullCircle(sector)) {
+        d = Drawing.symbolFuncs[0](r);
+    } else {
+        var arc = Math.abs(sector[1] - sector[0]);
+        var flags = arc <= 180 ? [0, 0, 0] : [0, 1, 0];
+        var xs = r * Math.cos(deg2rad(sector[0]));
+        var ys = -r * Math.sin(deg2rad(sector[0]));
+        var xe = r * Math.cos(deg2rad(sector[1]));
+        var ye = -r * Math.sin(deg2rad(sector[1]));
+
+        d = 'M' + [xs, ys] +
+            'A' + [r, r] + ' ' + flags + ' ' + [xe, ye];
+    }
+
+    return d;
+}
+
+function pathSectorClosed(r, sector, vangles) {
+    var d = pathSector(r, sector, vangles);
+    if(isFullCircle(sector) || vangles) return d;
+    return d + 'L0,0Z';
 }
 
 // TODO recycle this routine with the ones used for pie traces.
@@ -1199,6 +1570,11 @@ function strRotate(angle) {
     return 'rotate(' + angle + ')';
 }
 
+// to more easily catch 'almost zero' numbers in if-else blocks
+function clampTiny(v) {
+    return Math.abs(v) > 1e-10 ? v : 0;
+}
+
 // because Math.sign(Math.cos(Math.PI / 2)) === 1
 // oh javascript ;)
 function sign(v) {
diff --git a/src/traces/scatterpolar/plot.js b/src/traces/scatterpolar/plot.js
index 5ad3a019bef..e0018ddfc85 100644
--- a/src/traces/scatterpolar/plot.js
+++ b/src/traces/scatterpolar/plot.js
@@ -18,7 +18,7 @@ module.exports = function plot(gd, subplot, moduleCalcData) {
         xaxis: subplot.xaxis,
         yaxis: subplot.yaxis,
         plot: subplot.framework,
-        layerClipId: subplot._hasClipOnAxisFalse ? subplot.clipIds.circle : null
+        layerClipId: subplot._hasClipOnAxisFalse ? subplot.clipIds.forTraces : null
     };
 
     var radialAxis = subplot.radialAxis;
diff --git a/test/image/baselines/polar_polygon-grids.png b/test/image/baselines/polar_polygon-grids.png
new file mode 100644
index 00000000000..8a6e41ffdef
Binary files /dev/null and b/test/image/baselines/polar_polygon-grids.png differ
diff --git a/test/image/mocks/polar_polygon-grids.json b/test/image/mocks/polar_polygon-grids.json
new file mode 100644
index 00000000000..4936f1e6388
--- /dev/null
+++ b/test/image/mocks/polar_polygon-grids.json
@@ -0,0 +1,141 @@
+{
+  "data": [
+    {
+      "type": "scatterpolar",
+      "r": [5, 4, 2, 4, 5],
+      "theta": ["a", "b", "c", "d", "a"],
+      "fill": "toself"
+    },
+    {
+      "type": "scatterpolar",
+      "r": [1, 2.2, 2, 1.5, 1.5, 2, 3, 1],
+      "theta": ["a", "b", "c", "d", "e", "f", "g", "a"],
+      "fill": "toself",
+      "subplot": "polar2"
+    },
+    {
+      "type": "scatterpolar",
+      "r": [5, 4, 2, 4, 5],
+      "theta": ["a", "b", "c", "d", "a"],
+      "fill": "toself",
+      "subplot": "polar3"
+    },
+    {
+      "type": "scatterpolar",
+      "r": [45, 90, 180, 200, 300, 15, 20, 45],
+      "theta": ["a", "b", "c", "d", "b", "f", "a", "a"],
+      "subplot": "polar4"
+    },
+    {
+      "type": "scatterpolar",
+      "r": [5, 4, 2, 4, 5, 5],
+      "theta": ["b", "c", "d", "e", "a", "b"],
+      "fill": "toself"
+    },
+    {
+      "type": "scatterpolar",
+      "r": [500],
+      "theta": ["b"],
+      "name": "out-of-polygon (should not see it)",
+      "subplot": "polar4"
+    },
+    {
+      "type": "scatterpolar",
+      "r": [5, 4, 2, 4, 5, 5],
+      "theta": ["b", "c", "d", "e", "a", "b"],
+      "fill": "toself",
+      "subplot": "polar5"
+    },
+    {
+      "type": "scatterpolar",
+      "r": [5, 4, 2, 4, 5, 5],
+      "theta": ["b", "c", "d", "e", "a", "b"],
+      "fill": "toself",
+      "subplot": "polar6"
+    }
+  ],
+  "layout": {
+    "polar": {
+      "gridshape": "linear",
+      "domain": {
+        "x": [0, 0.46],
+        "y": [0.56, 1]
+      },
+      "radialaxis": {
+        "angle": 45,
+        "title": "angular period > _categories"
+      },
+      "angularaxis": {
+        "direction": "clockwise",
+        "period": 6
+      }
+    },
+    "polar2": {
+      "gridshape": "linear",
+      "domain": {
+        "x": [0, 0.46],
+        "y": [0, 0.44]
+      },
+      "angularaxis": {
+        "direction": "clockwise"
+      }
+    },
+    "polar3": {
+      "gridshape": "linear",
+      "domain": {
+        "x": [0.54, 1],
+        "y": [0.56, 1]
+      },
+      "sector": [-20, 200],
+      "radialaxis": {
+        "angle": 60,
+        "title": "snapped to 90"
+      },
+      "angularaxis": {
+        "categoryarray": ["d", "a", "c", "b"],
+        "direction": "clockwise"
+      }
+    },
+    "polar4": {
+      "gridshape": "linear",
+      "bgcolor": "#d3d3d3",
+      "domain": {
+        "x": [0.54, 1],
+        "y": [0, 0.44]
+      },
+      "angularaxis": {
+        "direction": "clockwise"
+      },
+      "radialaxis": {
+        "range": [0, 400]
+      }
+    },
+    "polar5": {
+      "gridshape": "linear",
+      "domain": {
+        "x": [0.4, 0.6],
+        "y": [0.4, 0.6]
+      },
+      "angularaxis": {
+        "direction": "clockwise",
+        "tickfont": {"color": "red"}
+      },
+      "sector": [-90, 90]
+    },
+    "polar6": {
+      "gridshape": "linear",
+      "domain": {
+        "x": [0.8, 1],
+        "y": [0.45, 0.65]
+      },
+      "angularaxis": {
+        "tickfont": {"color": "blue" }
+      },
+      "sector": [0, 180]
+    },
+    "width": 550,
+    "height": 550,
+    "margin": {"l": 40, "r": 40, "b": 60, "t": 20, "pad": 0},
+    "showlegend": false
+  }
+}
diff --git a/test/jasmine/assets/drag.js b/test/jasmine/assets/drag.js
index 35b628e0e07..10c8320ce91 100644
--- a/test/jasmine/assets/drag.js
+++ b/test/jasmine/assets/drag.js
@@ -7,7 +7,7 @@ var getNodeCoords = require('./get_node_coords');
  * optionally specify an edge ('n', 'se', 'w' etc)
  * to grab it by an edge or corner (otherwise the middle is used)
  */
-module.exports = function(node, dx, dy, edge, x0, y0, nsteps) {
+function drag(node, dx, dy, edge, x0, y0, nsteps) {
     nsteps = nsteps || 1;
 
     var coords = getNodeCoords(node, edge);
@@ -32,7 +32,7 @@ module.exports = function(node, dx, dy, edge, x0, y0, nsteps) {
     });
 
     return promise;
-};
+}
 
 function waitForDragCover() {
     return new Promise(function(resolve) {
@@ -75,3 +75,7 @@ function waitForDragCoverRemoval() {
         }, interval);
     });
 }
+
+module.exports = drag;
+drag.waitForDragCover = waitForDragCover;
+drag.waitForDragCoverRemoval = waitForDragCoverRemoval;
diff --git a/test/jasmine/tests/polar_test.js b/test/jasmine/tests/polar_test.js
index 32ca0d61b6b..f7fb263a07c 100644
--- a/test/jasmine/tests/polar_test.js
+++ b/test/jasmine/tests/polar_test.js
@@ -6,7 +6,7 @@ var constants = require('@src/plots/polar/constants');
 var d3 = require('d3');
 var createGraphDiv = require('../assets/create_graph_div');
 var destroyGraphDiv = require('../assets/destroy_graph_div');
-var fail = require('../assets/fail_test');
+var failTest = require('../assets/fail_test');
 var mouseEvent = require('../assets/mouse_event');
 var click = require('../assets/click');
 var doubleClick = require('../assets/double_click');
@@ -52,7 +52,7 @@ describe('Test legacy polar plots logs:', function() {
                 expect(Lib.log).toHaveBeenCalledTimes(1);
                 expect(Lib.log).toHaveBeenCalledWith('Legacy polar charts are deprecated!');
             })
-            .catch(fail)
+            .catch(failTest)
             .then(done);
         });
     });
@@ -265,7 +265,7 @@ describe('Test relayout on polar subplots:', function() {
         .then(function() {
             _assert(dflt);
         })
-        .catch(fail)
+        .catch(failTest)
         .then(done);
     });
 
@@ -295,7 +295,7 @@ describe('Test relayout on polar subplots:', function() {
             expect(gd._fullLayout.polar.radialaxis.range)
                 .toBeCloseToArray([0, 11.225]);
         })
-        .catch(fail)
+        .catch(failTest)
         .then(done);
     });
 
@@ -321,7 +321,7 @@ describe('Test relayout on polar subplots:', function() {
             // if they're the same, the tick label position did not update
             expect(pos1).not.toBeCloseTo2DArray(pos0);
         })
-        .catch(fail)
+        .catch(failTest)
         .then(done);
     });
 
@@ -357,7 +357,7 @@ describe('Test relayout on polar subplots:', function() {
         .then(function() {
             check(8, 'M-1.5,0h-5');
         })
-        .catch(fail)
+        .catch(failTest)
         .then(done);
     });
 
@@ -450,7 +450,7 @@ describe('Test relayout on polar subplots:', function() {
                 '.angular-axis > path.angulartick', assertCnt
             );
         })
-        .catch(fail)
+        .catch(failTest)
         .then(done);
     });
 
@@ -506,7 +506,7 @@ describe('Test relayout on polar subplots:', function() {
         .then(function() {
             assertTitle('yo2', false);
         })
-        .catch(fail)
+        .catch(failTest)
         .then(done);
     });
 
@@ -522,7 +522,7 @@ describe('Test relayout on polar subplots:', function() {
 
             var clipCnt = 0;
             d3.selectAll('clipPath').each(function() {
-                if(/polar-circle$/.test(this.id)) clipCnt++;
+                if(/polar-for-traces/.test(this.id)) clipCnt++;
             });
             expect(clipCnt).toBe(exp.clip, '# clip paths');
         }
@@ -540,7 +540,7 @@ describe('Test relayout on polar subplots:', function() {
         .then(function() {
             _assert({subplot: 1, clip: 1, rtitle: 1});
         })
-        .catch(fail)
+        .catch(failTest)
         .then(done);
     });
 
@@ -599,7 +599,46 @@ describe('Test relayout on polar subplots:', function() {
                 sampleXY: [-25, 43]
             });
         })
-        .catch(fail)
+        .catch(failTest)
+        .then(done);
+    });
+
+    it('should be able to relayout *gridshape*', function(done) {
+        var gd = createGraphDiv();
+
+        // check number of arcs ('A') or lines ('L') in svg paths
+        function _assert(msg, exp) {
+            var sp = d3.select(gd).select('g.polar');
+
+            function assertLetterCount(query) {
+                var d = sp.select(query).attr('d');
+                var re = new RegExp(exp.letter, 'g');
+                var actual = (d.match(re) || []).length;
+                expect(actual).toBe(exp.cnt, msg + ' - ' + query);
+            }
+
+            assertLetterCount('.plotbg > path');
+            assertLetterCount('.radial-grid > .x > path');
+            assertLetterCount('.angular-line > path');
+        }
+
+        Plotly.plot(gd, [{
+            type: 'scatterpolar',
+            r: [1, 2, 3, 2, 3, 1],
+            theta: ['a', 'b', 'c', 'd', 'e', 'a']
+        }])
+        .then(function() {
+            _assert('base', {letter: 'A', cnt: 2});
+            return Plotly.relayout(gd, 'polar.gridshape', 'linear');
+        })
+        .then(function() {
+            _assert('relayout -> linear', {letter: 'L', cnt: 5});
+            return Plotly.relayout(gd, 'polar.gridshape', 'circular');
+        })
+        .then(function() {
+            _assert('relayout -> circular', {letter: 'A', cnt: 2});
+        })
+        .catch(failTest)
         .then(done);
     });
 });
@@ -794,7 +833,7 @@ describe('Test polar interactions:', function() {
                 plotly_relayout: 1
             }, 'after right click');
         })
-        .catch(fail)
+        .catch(failTest)
         .then(done);
     });
 
@@ -885,7 +924,7 @@ describe('Test polar interactions:', function() {
             expect(eventCnts.plotly_relayout)
                 .toBe(relayoutNumber, 'no new relayout events after *not far enough* cases');
         })
-        .catch(fail)
+        .catch(failTest)
         .then(done);
     });
 
@@ -969,7 +1008,7 @@ describe('Test polar interactions:', function() {
         .then(function() {
             expect(eventCnts.plotly_relayout).toBe(8, 'total # of relayout events');
         })
-        .catch(fail)
+        .catch(failTest)
         .then(done);
     });
 
@@ -1034,7 +1073,268 @@ describe('Test polar interactions:', function() {
         .then(function() {
             expect(eventCnts.plotly_relayout).toBe(4, 'total # of relayout events');
         })
-        .catch(fail)
+        .catch(failTest)
+        .then(done);
+    });
+});
+
+describe('Test polar *gridshape linear* interactions', function() {
+    var gd;
+
+    beforeEach(function() {
+        jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000;
+        gd = createGraphDiv();
+    });
+
+    afterEach(destroyGraphDiv);
+
+    it('should snap radial axis rotation to polygon vertex angles', function(done) {
+        var dragPos0 = [150, 25];
+        var dragPos1 = [316, 82];
+        var evtCnt = 0;
+
+        // use 'special' drag method - as we need two mousemove events
+        // to activate the radial drag mode
+        function _drag(p0, dp) {
+            var node = d3.select('.polar > .draglayer > .radialdrag').node();
+            return drag(node, dp[0], dp[1], null, p0[0], p0[1], 2);
+        }
+
+        function _assert(msg, angle) {
+            expect(gd._fullLayout.polar.radialaxis.angle)
+                .toBeCloseTo(angle, 1, msg + ' - angle');
+        }
+
+        Plotly.plot(gd, [{
+            type: 'scatterpolar',
+            // octogons have nice angles
+            r: [1, 2, 3, 2, 3, 1, 2, 1, 2],
+            theta: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'a']
+        }], {
+            polar: {
+                gridshape: 'linear',
+                angularaxis: {direction: 'clockwise'},
+                radialaxis: {angle: 90}
+            },
+            width: 400,
+            height: 400,
+            margin: {l: 50, t: 50, b: 50, r: 50},
+            // to avoid dragging on hover labels
+            hovermode: false
+        })
+        .then(function() {
+            gd.on('plotly_relayout', function() { evtCnt++; });
+        })
+        .then(function() { _assert('base', 90); })
+        .then(function() { return _drag(dragPos0, [100, 50]); })
+        .then(function() { _assert('rotate right', 45); })
+        .then(function() { return _drag(dragPos1, [20, 20]); })
+        .then(function() { _assert('rotate right, snapped back', 45); })
+        .then(function() { return _drag(dragPos1, [-100, -50]); })
+        .then(function() { _assert('rotate left', 90); })
+        .then(function() { expect(evtCnt).toBe(3); })
+        .catch(failTest)
+        .then(done);
+    });
+
+    it('should rotate all non-symmetrical layers on angular drag', function(done) {
+        var evtCnt = 0;
+        var evtData = {};
+        var dragCoverNode;
+        var p1;
+
+        var layersRotateFromZero = ['.plotbg > path', '.radial-grid', '.angular-line > path'];
+        var layersRotateFromRadialAxis = ['.radial-axis', '.radial-line > line'];
+
+        function _assertTransformRotate(msg, query, rot) {
+            var sp = d3.select(gd).select('g.polar');
+            var t = sp.select(query).attr('transform');
+            var rotate = (t.split('rotate(')[1] || '').split(')')[0];
+            if(rot === null) {
+                expect(rotate).toBe('', msg + ' - ' + query);
+            } else {
+                expect(Number(rotate)).toBeCloseTo(rot, 1, msg + ' - ' + query);
+            }
+        }
+
+        function _dragStart(p0, dp) {
+            var node = d3.select('.polar > .draglayer > .angulardrag').node();
+            mouseEvent('mousemove', p0[0], p0[1], {element: node});
+            mouseEvent('mousedown', p0[0], p0[1], {element: node});
+
+            var promise = drag.waitForDragCover().then(function(dcn) {
+                dragCoverNode = dcn;
+                p1 = [p0[0] + dp[0], p0[1] + dp[1]];
+                mouseEvent('mousemove', p1[0], p1[1], {element: dragCoverNode});
+            });
+            return promise;
+        }
+
+        function _assertAndDragEnd(msg, exp) {
+            layersRotateFromZero.forEach(function(q) {
+                _assertTransformRotate(msg, q, exp.fromZero);
+            });
+            layersRotateFromRadialAxis.forEach(function(q) {
+                _assertTransformRotate(msg, q, exp.fromRadialAxis);
+            });
+
+            mouseEvent('mouseup', p1[0], p1[1], {element: dragCoverNode});
+            return drag.waitForDragCoverRemoval();
+        }
+
+        Plotly.plot(gd, [{
+            type: 'scatterpolar',
+            r: [1, 2, 3, 2, 3],
+            theta: ['a', 'b', 'c', 'd', 'e']
+        }], {
+            polar: {
+                gridshape: 'linear',
+                angularaxis: {direction: 'clockwise'},
+                radialaxis: {angle: 90}
+            },
+            width: 400,
+            height: 400,
+            margin: {l: 50, t: 50, b: 50, r: 50}
+        })
+        .then(function() {
+            gd.on('plotly_relayout', function(d) {
+                evtCnt++;
+                evtData = d;
+            });
+        })
+        .then(function() {
+            layersRotateFromZero.forEach(function(q) {
+                _assertTransformRotate('base', q, null);
+            });
+            layersRotateFromRadialAxis.forEach(function(q) {
+                _assertTransformRotate('base', q, -90);
+            });
+        })
+        .then(function() { return _dragStart([150, 20], [30, 30]); })
+        .then(function() {
+            return _assertAndDragEnd('rotate clockwise', {
+                fromZero: 7.2,
+                fromRadialAxis: -82.8
+            });
+        })
+        .then(function() {
+            expect(evtCnt).toBe(1, '# of plotly_relayout calls');
+            expect(evtData['polar.angularaxis.rotation'])
+                .toBeCloseTo(82.8, 1, 'polar.angularaxis.rotation event data');
+            // have to rotate radial axis too here, to ensure it remains 'on scale'
+            expect(evtData['polar.radialaxis.angle'])
+                .toBeCloseTo(82.8, 1, 'polar.radialaxis.angle event data');
+        })
+        .catch(failTest)
+        .then(done);
+    });
+
+    it('should place zoombox handles at correct place on main drag', function(done) {
+        var dragCoverNode;
+        var p1;
+
+        // d attr to array of segment [x,y]
+        function path2coords(path) {
+            if(!path.size()) return [[]];
+            return path.attr('d')
+                .replace(/Z/g, '')
+                .split('M')
+                .filter(Boolean)
+                .map(function(s) {
+                    return s.split('L')
+                        .map(function(s) { return s.split(',').map(Number); });
+                })
+                .reduce(function(a, b) { return a.concat(b); });
+        }
+
+        function _dragStart(p0, dp) {
+            var node = d3.select('.polar > .draglayer > .maindrag').node();
+            mouseEvent('mousemove', p0[0], p0[1], {element: node});
+            mouseEvent('mousedown', p0[0], p0[1], {element: node});
+
+            var promise = drag.waitForDragCover().then(function(dcn) {
+                dragCoverNode = dcn;
+                p1 = [p0[0] + dp[0], p0[1] + dp[1]];
+                mouseEvent('mousemove', p1[0], p1[1], {element: dragCoverNode});
+            });
+            return promise;
+        }
+
+        function _assertAndDragEnd(msg, exp) {
+            var zl = d3.select(gd).select('g.zoomlayer');
+
+            expect(path2coords(zl.select('.zoombox')))
+                .toBeCloseTo2DArray(exp.zoombox, 2, msg + ' - zoombox');
+            expect(path2coords(zl.select('.zoombox-corners')))
+                .toBeCloseTo2DArray(exp.corners, 2, msg + ' - corners');
+
+            mouseEvent('mouseup', p1[0], p1[1], {element: dragCoverNode});
+            return drag.waitForDragCoverRemoval();
+        }
+
+        Plotly.plot(gd, [{
+            type: 'scatterpolar',
+            r: [1, 2, 3, 2, 3],
+            theta: ['a', 'b', 'c', 'd', 'e']
+        }], {
+            polar: {
+                gridshape: 'linear',
+                angularaxis: {direction: 'clockwise'}
+            },
+            width: 400,
+            height: 400,
+            margin: {l: 50, t: 50, b: 50, r: 50}
+        })
+        .then(function() { return _dragStart([170, 170], [220, 220]); })
+        .then(function() {
+            _assertAndDragEnd('drag outward toward bottom right', {
+                zoombox: [
+                    [-142.658, -46.353], [-88.167, 121.352],
+                    [88.167, 121.352], [142.658, -46.352],
+                    [0, -150], [-142.658, -46.352],
+                    [-142.658, -46.352], [-88.167, 121.352],
+                    [88.167, 121.352], [142.658, -46.352],
+                    [0, -150], [-142.658, -46.352],
+                    [-49.261, -16.005], [-30.445, 41.904],
+                    [30.44508691777904, 41.904], [49.261, -16.005],
+                    [0, -51.796], [-49.261, -16.005]
+                ],
+                corners: [
+                    [-13.342, -39.630], [-33.567, -24.935],
+                    [-35.918, -28.171], [-15.693, -42.866],
+                    [-60.040, -103.905], [-80.266, -89.210],
+                    [-82.617, -92.446], [-62.392, -107.141]
+                ]
+            });
+        })
+        .then(function() {
+            return Plotly.relayout(gd, 'polar.sector', [-90, 90]);
+        })
+        .then(function() { return _dragStart([200, 200], [200, 230]); })
+        .then(function() {
+            _assertAndDragEnd('half-sector, drag outward', {
+                zoombox: [
+                    [0, 121.352], [88.167, 121.352],
+                    [142.658, -46.352], [0, -150],
+                    [0, -150], [0, 0],
+                    [0, 121.352], [0, 121.352],
+                    [88.167, 121.352], [142.658, -46.352],
+                    [0, -150], [0, -150],
+                    [0, 0], [0, 121.352],
+                    [0, 71.329], [51.823, 71.329],
+                    [83.852, -27.245], [0, -88.16778784387097],
+                    [0, -88.167], [0, 0],
+                    [0, 71.329]
+                ],
+                corners: [
+                    [73.602, 10.771], [65.877, 34.548],
+                    [62.073, 33.312], [69.798, 9.535],
+                    [121.177, 26.229], [113.452, 50.006],
+                    [109.648, 48.770], [117.373, 24.993]
+                ]
+            });
+        })
+        .catch(failTest)
         .then(done);
     });
 });