Merge pull request #2812 from mbasaglia/offset-path

OffsetPath and ZigZag modifiers
diff --git a/player/js/elements/canvasElements/CVShapeElement.js b/player/js/elements/canvasElements/CVShapeElement.js
index 323823e..f37cf51 100644
--- a/player/js/elements/canvasElements/CVShapeElement.js
+++ b/player/js/elements/canvasElements/CVShapeElement.js
@@ -219,7 +219,7 @@
       if (!processedPos) {
         itemsData[i] = this.createShapeElement(arr[i]);
       }
-    } else if (arr[i].ty === 'tm' || arr[i].ty === 'rd' || arr[i].ty === 'pb') {
+    } else if (arr[i].ty === 'tm' || arr[i].ty === 'rd' || arr[i].ty === 'pb' || arr[i].ty === 'zz' || arr[i].ty === 'op') {
       if (!processedPos) {
         modifier = ShapeModifiers.getModifier(arr[i].ty);
         modifier.init(this, arr[i]);
diff --git a/player/js/elements/svgElements/SVGShapeElement.js b/player/js/elements/svgElements/SVGShapeElement.js
index 8dd408a..d71d17c 100644
--- a/player/js/elements/svgElements/SVGShapeElement.js
+++ b/player/js/elements/svgElements/SVGShapeElement.js
@@ -289,7 +289,7 @@
         itemsData[i] = this.createShapeElement(arr[i], ownTransformers, level);
       }
       this.setElementStyles(itemsData[i]);
-    } else if (arr[i].ty === 'tm' || arr[i].ty === 'rd' || arr[i].ty === 'ms' || arr[i].ty === 'pb') {
+    } else if (arr[i].ty === 'tm' || arr[i].ty === 'rd' || arr[i].ty === 'ms' || arr[i].ty === 'pb' || arr[i].ty === 'zz' || arr[i].ty === 'op') {
       if (!processedPos) {
         modifier = ShapeModifiers.getModifier(arr[i].ty);
         modifier.init(this, arr[i]);
diff --git a/player/js/modules/canvas_light.js b/player/js/modules/canvas_light.js
index 3a48453..0af0510 100644
--- a/player/js/modules/canvas_light.js
+++ b/player/js/modules/canvas_light.js
@@ -4,6 +4,8 @@
 import PuckerAndBloatModifier from '../utils/shapes/PuckerAndBloatModifier';
 import RepeaterModifier from '../utils/shapes/RepeaterModifier';
 import RoundCornersModifier from '../utils/shapes/RoundCornersModifier';
+import ZigZagModifier from '../utils/shapes/ZigZagModifier';
+import OffsetPathModifier from '../utils/shapes/OffsetPathModifier';
 import CanvasRenderer from '../renderers/CanvasRenderer';
 import {
   registerRenderer,
@@ -17,5 +19,7 @@
 ShapeModifiers.registerModifier('pb', PuckerAndBloatModifier);
 ShapeModifiers.registerModifier('rp', RepeaterModifier);
 ShapeModifiers.registerModifier('rd', RoundCornersModifier);
+ShapeModifiers.registerModifier('zz', ZigZagModifier);
+ShapeModifiers.registerModifier('op', OffsetPathModifier);
 
 export default lottie;
diff --git a/player/js/modules/full.js b/player/js/modules/full.js
index ae8e7eb..7e42cb7 100644
--- a/player/js/modules/full.js
+++ b/player/js/modules/full.js
@@ -7,6 +7,8 @@
 import PuckerAndBloatModifier from '../utils/shapes/PuckerAndBloatModifier';
 import RepeaterModifier from '../utils/shapes/RepeaterModifier';
 import RoundCornersModifier from '../utils/shapes/RoundCornersModifier';
+import ZigZagModifier from '../utils/shapes/ZigZagModifier';
+import OffsetPathModifier from '../utils/shapes/OffsetPathModifier';
 import CanvasRenderer from '../renderers/CanvasRenderer';
 import HybridRenderer from '../renderers/HybridRenderer';
 import SVGRenderer from '../renderers/SVGRenderer';
@@ -37,6 +39,8 @@
 ShapeModifiers.registerModifier('pb', PuckerAndBloatModifier);
 ShapeModifiers.registerModifier('rp', RepeaterModifier);
 ShapeModifiers.registerModifier('rd', RoundCornersModifier);
+ShapeModifiers.registerModifier('zz', ZigZagModifier);
+ShapeModifiers.registerModifier('op', OffsetPathModifier);
 
 // Registering expression plugin
 setExpressionsPlugin(Expressions);
diff --git a/player/js/modules/html_light.js b/player/js/modules/html_light.js
index c22312d..185a795 100644
--- a/player/js/modules/html_light.js
+++ b/player/js/modules/html_light.js
@@ -4,6 +4,8 @@
 import PuckerAndBloatModifier from '../utils/shapes/PuckerAndBloatModifier';
 import RepeaterModifier from '../utils/shapes/RepeaterModifier';
 import RoundCornersModifier from '../utils/shapes/RoundCornersModifier';
+import ZigZagModifier from '../utils/shapes/ZigZagModifier';
+import OffsetPathModifier from '../utils/shapes/OffsetPathModifier';
 import HybridRenderer from '../renderers/HybridRenderer';
 import {
   registerRenderer,
@@ -17,5 +19,7 @@
 ShapeModifiers.registerModifier('pb', PuckerAndBloatModifier);
 ShapeModifiers.registerModifier('rp', RepeaterModifier);
 ShapeModifiers.registerModifier('rd', RoundCornersModifier);
+ShapeModifiers.registerModifier('zz', ZigZagModifier);
+ShapeModifiers.registerModifier('op', OffsetPathModifier);
 
 export default lottie;
diff --git a/player/js/modules/svg_light.js b/player/js/modules/svg_light.js
index 45c4b82..8df0eba 100644
--- a/player/js/modules/svg_light.js
+++ b/player/js/modules/svg_light.js
@@ -4,6 +4,8 @@
 import PuckerAndBloatModifier from '../utils/shapes/PuckerAndBloatModifier';
 import RepeaterModifier from '../utils/shapes/RepeaterModifier';
 import RoundCornersModifier from '../utils/shapes/RoundCornersModifier';
+import ZigZagModifier from '../utils/shapes/ZigZagModifier';
+import OffsetPathModifier from '../utils/shapes/OffsetPathModifier';
 import SVGRenderer from '../renderers/SVGRenderer';
 import {
   registerRenderer,
@@ -17,5 +19,7 @@
 ShapeModifiers.registerModifier('pb', PuckerAndBloatModifier);
 ShapeModifiers.registerModifier('rp', RepeaterModifier);
 ShapeModifiers.registerModifier('rd', RoundCornersModifier);
+ShapeModifiers.registerModifier('zz', ZigZagModifier);
+ShapeModifiers.registerModifier('op', OffsetPathModifier);
 
 export default lottie;
diff --git a/player/js/utils/PolynomialBezier.js b/player/js/utils/PolynomialBezier.js
new file mode 100644
index 0000000..20bc5fe
--- /dev/null
+++ b/player/js/utils/PolynomialBezier.js
@@ -0,0 +1,248 @@
+function floatEqual(a, b) {
+  return Math.abs(a - b) * 100000 <= Math.min(Math.abs(a), Math.abs(b));
+}
+
+function floatZero(f) {
+  return Math.abs(f) <= 0.00001;
+}
+
+function lerp(p0, p1, amount) {
+  return p0 * (1 - amount) + p1 * amount;
+}
+
+function lerpPoint(p0, p1, amount) {
+  return [lerp(p0[0], p1[0], amount), lerp(p0[1], p1[1], amount)];
+}
+
+function quadRoots(a, b, c) {
+  // no root
+  if (a === 0) return [];
+  var s = b * b - 4 * a * c;
+  // Complex roots
+  if (s < 0) return [];
+  var singleRoot = -b / (2 * a);
+  // 1 root
+  if (s === 0) return [singleRoot];
+  var delta = Math.sqrt(s) / (2 * a);
+  // 2 roots
+  return [singleRoot - delta, singleRoot + delta];
+}
+
+function polynomialCoefficients(p0, p1, p2, p3) {
+  return [
+    -p0 + 3 * p1 - 3 * p2 + p3,
+    3 * p0 - 6 * p1 + 3 * p2,
+    -3 * p0 + 3 * p1,
+    p0,
+  ];
+}
+
+function singlePoint(p) {
+  return new PolynomialBezier(p, p, p, p, false);
+}
+
+function PolynomialBezier(p0, p1, p2, p3, linearize) {
+  if (linearize && pointEqual(p0, p1)) {
+    p1 = lerpPoint(p0, p3, 1 / 3);
+  }
+  if (linearize && pointEqual(p2, p3)) {
+    p2 = lerpPoint(p0, p3, 2 / 3);
+  }
+  var coeffx = polynomialCoefficients(p0[0], p1[0], p2[0], p3[0]);
+  var coeffy = polynomialCoefficients(p0[1], p1[1], p2[1], p3[1]);
+  this.a = [coeffx[0], coeffy[0]];
+  this.b = [coeffx[1], coeffy[1]];
+  this.c = [coeffx[2], coeffy[2]];
+  this.d = [coeffx[3], coeffy[3]];
+  this.points = [p0, p1, p2, p3];
+}
+PolynomialBezier.prototype.point = function (t) {
+  return [
+    (((this.a[0] * t) + this.b[0]) * t + this.c[0]) * t + this.d[0],
+    (((this.a[1] * t) + this.b[1]) * t + this.c[1]) * t + this.d[1],
+  ];
+};
+PolynomialBezier.prototype.derivative = function (t) {
+  return [
+    (3 * t * this.a[0] + 2 * this.b[0]) * t + this.c[0],
+    (3 * t * this.a[1] + 2 * this.b[1]) * t + this.c[1],
+  ];
+};
+PolynomialBezier.prototype.tangentAngle = function (t) {
+  var p = this.derivative(t);
+  return Math.atan2(p[1], p[0]);
+};
+PolynomialBezier.prototype.normalAngle = function (t) {
+  var p = this.derivative(t);
+  return Math.atan2(p[0], p[1]);
+};
+
+PolynomialBezier.prototype.inflectionPoints = function () {
+  var denom = this.a[1] * this.b[0] - this.a[0] * this.b[1];
+  if (floatZero(denom)) return [];
+  var tcusp = (-0.5 * (this.a[1] * this.c[0] - this.a[0] * this.c[1])) / denom;
+  var square = tcusp * tcusp - ((1 / 3) * (this.b[1] * this.c[0] - this.b[0] * this.c[1])) / denom;
+  if (square < 0) return [];
+  var root = Math.sqrt(square);
+  if (floatZero(root)) {
+    if (root > 0 && root < 1) return [tcusp];
+    return [];
+  }
+  return [tcusp - root, tcusp + root].filter(function (r) { return r > 0 && r < 1; });
+};
+PolynomialBezier.prototype.split = function (t) {
+  if (t <= 0) return [singlePoint(this.points[0]), this];
+  if (t >= 1) return [this, singlePoint(this.points[this.points.length - 1])];
+  var p10 = lerpPoint(this.points[0], this.points[1], t);
+  var p11 = lerpPoint(this.points[1], this.points[2], t);
+  var p12 = lerpPoint(this.points[2], this.points[3], t);
+  var p20 = lerpPoint(p10, p11, t);
+  var p21 = lerpPoint(p11, p12, t);
+  var p3 = lerpPoint(p20, p21, t);
+  return [
+    new PolynomialBezier(this.points[0], p10, p20, p3, true),
+    new PolynomialBezier(p3, p21, p12, this.points[3], true),
+  ];
+};
+function extrema(bez, comp) {
+  var min = bez.points[0][comp];
+  var max = bez.points[bez.points.length - 1][comp];
+  if (min > max) {
+    var e = max;
+    max = min;
+    min = e;
+  }
+  // Derivative roots to find min/max
+  var f = quadRoots(3 * bez.a[comp], 2 * bez.b[comp], bez.c[comp]);
+  for (var i = 0; i < f.length; i += 1) {
+    if (f[i] > 0 && f[i] < 1) {
+      var val = bez.point(f[i])[comp];
+      if (val < min) min = val;
+      else if (val > max) max = val;
+    }
+  }
+  return {
+    min: min,
+    max: max,
+  };
+}
+PolynomialBezier.prototype.bounds = function () {
+  return {
+    x: extrema(this, 0),
+    y: extrema(this, 1),
+  };
+};
+PolynomialBezier.prototype.boundingBox = function () {
+  var bounds = this.bounds();
+  return {
+    left: bounds.x.min,
+    right: bounds.x.max,
+    top: bounds.y.min,
+    bottom: bounds.y.max,
+    width: bounds.x.max - bounds.x.min,
+    height: bounds.y.max - bounds.y.min,
+    cx: (bounds.x.max + bounds.x.min) / 2,
+    cy: (bounds.y.max + bounds.y.min) / 2,
+  };
+};
+
+function intersectData(bez, t1, t2) {
+  var box = bez.boundingBox();
+  return {
+    cx: box.cx,
+    cy: box.cy,
+    width: box.width,
+    height: box.height,
+    bez: bez,
+    t: (t1 + t2) / 2,
+    t1: t1,
+    t2: t2,
+  };
+}
+function splitData(data) {
+  var split = data.bez.split(0.5);
+  return [
+    intersectData(split[0], data.t1, data.t),
+    intersectData(split[1], data.t, data.t2),
+  ];
+}
+
+function boxIntersect(b1, b2) {
+  return Math.abs(b1.cx - b2.cx) * 2 < b1.width + b2.width
+    && Math.abs(b1.cy - b2.cy) * 2 < b1.height + b2.height;
+}
+
+function intersectsImpl(d1, d2, depth, tolerance, intersections, maxRecursion) {
+  if (!boxIntersect(d1, d2)) return;
+  if (depth >= maxRecursion || (d1.width <= tolerance && d1.height <= tolerance && d2.width <= tolerance && d2.height <= tolerance)) {
+    intersections.push([d1.t, d2.t]);
+    return;
+  }
+  var d1s = splitData(d1);
+  var d2s = splitData(d2);
+  intersectsImpl(d1s[0], d2s[0], depth + 1, tolerance, intersections, maxRecursion);
+  intersectsImpl(d1s[0], d2s[1], depth + 1, tolerance, intersections, maxRecursion);
+  intersectsImpl(d1s[1], d2s[0], depth + 1, tolerance, intersections, maxRecursion);
+  intersectsImpl(d1s[1], d2s[1], depth + 1, tolerance, intersections, maxRecursion);
+}
+
+PolynomialBezier.prototype.intersections = function (other, tolerance, maxRecursion) {
+  if (tolerance === undefined) tolerance = 2;
+  if (maxRecursion === undefined) maxRecursion = 7;
+  var intersections = [];
+  intersectsImpl(intersectData(this, 0, 1), intersectData(other, 0, 1), 0, tolerance, intersections, maxRecursion);
+  return intersections;
+};
+
+PolynomialBezier.shapeSegment = function (shapePath, index) {
+  var nextIndex = (index + 1) % shapePath.length();
+  return new PolynomialBezier(shapePath.v[index], shapePath.o[index], shapePath.i[nextIndex], shapePath.v[nextIndex], true);
+};
+
+function crossProduct(a, b) {
+  return [
+    a[1] * b[2] - a[2] * b[1],
+    a[2] * b[0] - a[0] * b[2],
+    a[0] * b[1] - a[1] * b[0],
+  ];
+}
+
+function lineIntersection(start1, end1, start2, end2) {
+  var v1 = [start1[0], start1[1], 1];
+  var v2 = [end1[0], end1[1], 1];
+  var v3 = [start2[0], start2[1], 1];
+  var v4 = [end2[0], end2[1], 1];
+
+  var r = crossProduct(
+    crossProduct(v1, v2),
+    crossProduct(v3, v4)
+  );
+
+  if (floatZero(r[2])) return null;
+
+  return [r[0] / r[2], r[1] / r[2]];
+}
+
+function polarOffset(p, angle, length) {
+  return [
+    p[0] + Math.cos(angle) * length,
+    p[1] - Math.sin(angle) * length,
+  ];
+}
+
+function pointDistance(p1, p2) {
+  return Math.hypot(p1[0] - p2[0], p1[1] - p2[1]);
+}
+
+function pointEqual(p1, p2) {
+  return floatEqual(p1[0], p2[0]) && floatEqual(p1[1], p2[1]);
+}
+
+export {
+  PolynomialBezier,
+  lineIntersection,
+  polarOffset,
+  pointDistance,
+  pointEqual,
+  floatEqual,
+};
diff --git a/player/js/utils/shapes/OffsetPathModifier.js b/player/js/utils/shapes/OffsetPathModifier.js
new file mode 100644
index 0000000..a8bfab0
--- /dev/null
+++ b/player/js/utils/shapes/OffsetPathModifier.js
@@ -0,0 +1,290 @@
+import {
+  roundCorner,
+} from '../common';
+import {
+  extendPrototype,
+} from '../functionExtensions';
+import PropertyFactory from '../PropertyFactory';
+import shapePool from '../pooling/shape_pool';
+import {
+  ShapeModifier,
+} from './ShapeModifiers';
+import {
+  PolynomialBezier,
+  polarOffset,
+  lineIntersection,
+  pointDistance,
+  pointEqual,
+  floatEqual,
+} from '../PolynomialBezier';
+
+function linearOffset(p1, p2, amount) {
+  var angle = Math.atan2(p2[0] - p1[0], p2[1] - p1[1]);
+  return [
+    polarOffset(p1, angle, amount),
+    polarOffset(p2, angle, amount),
+  ];
+}
+
+function offsetSegment(segment, amount) {
+  var p0; var p1a; var p1b; var p2b; var p2a; var
+    p3;
+  var e;
+  e = linearOffset(segment.points[0], segment.points[1], amount);
+  p0 = e[0];
+  p1a = e[1];
+  e = linearOffset(segment.points[1], segment.points[2], amount);
+  p1b = e[0];
+  p2b = e[1];
+  e = linearOffset(segment.points[2], segment.points[3], amount);
+  p2a = e[0];
+  p3 = e[1];
+  var p1 = lineIntersection(p0, p1a, p1b, p2b);
+  if (p1 === null) p1 = p1a;
+  var p2 = lineIntersection(p2a, p3, p1b, p2b);
+  if (p2 === null) p2 = p2a;
+
+  return new PolynomialBezier(p0, p1, p2, p3);
+}
+
+function joinLines(outputBezier, seg1, seg2, lineJoin, miterLimit) {
+  var p0 = seg1.points[3];
+  var p1 = seg2.points[0];
+
+  // Bevel
+  if (lineJoin === 3) return p0;
+
+  // Connected, they don't need a joint
+  if (pointEqual(p0, p1)) return p0;
+
+  // Round
+  if (lineJoin === 2) {
+    var angleOut = -seg1.tangentAngle(1);
+    var angleIn = -seg2.tangentAngle(0) + Math.PI;
+    var center = lineIntersection(
+      p0,
+      polarOffset(p0, angleOut + Math.PI / 2, 100),
+      p1,
+      polarOffset(p1, angleOut + Math.PI / 2, 100)
+    );
+    var radius = center ? pointDistance(center, p0) : pointDistance(p0, p1) / 2;
+
+    var tan = polarOffset(p0, angleOut, 2 * radius * roundCorner);
+    outputBezier.setXYAt(tan[0], tan[1], 'o', outputBezier.length() - 1);
+
+    tan = polarOffset(p1, angleIn, 2 * radius * roundCorner);
+    outputBezier.setTripleAt(p1[0], p1[1], p1[0], p1[1], tan[0], tan[1], outputBezier.length());
+
+    return p1;
+  }
+
+  // Miter
+  var t0 = pointEqual(p0, seg1.points[2]) ? seg1.points[0] : seg1.points[2];
+  var t1 = pointEqual(p1, seg2.points[1]) ? seg2.points[3] : seg2.points[1];
+  var intersection = lineIntersection(t0, p0, p1, t1);
+  if (intersection && pointDistance(intersection, p0) < miterLimit) {
+    outputBezier.setTripleAt(
+      intersection[0],
+      intersection[1],
+      intersection[0],
+      intersection[1],
+      intersection[0],
+      intersection[1],
+      outputBezier.length()
+    );
+    return intersection;
+  }
+
+  return p0;
+}
+
+function getIntersection(a, b) {
+  const intersect = a.intersections(b);
+
+  if (intersect.length && floatEqual(intersect[0][0], 1)) intersect.shift();
+
+  if (intersect.length) return intersect[0];
+
+  return null;
+}
+
+function pruneSegmentIntersection(a, b) {
+  var outa = a.slice();
+  var outb = b.slice();
+  var intersect = getIntersection(a[a.length - 1], b[0]);
+  if (intersect) {
+    outa[a.length - 1] = a[a.length - 1].split(intersect[0])[0];
+    outb[0] = b[0].split(intersect[1])[1];
+  }
+  if (a.length > 1 && b.length > 1) {
+    intersect = getIntersection(a[0], b[b.length - 1]);
+    if (intersect) {
+      return [
+        [a[0].split(intersect[0])[0]],
+        [b[b.length - 1].split(intersect[1])[1]],
+      ];
+    }
+  }
+  return [outa, outb];
+}
+
+function pruneIntersections(segments) {
+  var e;
+  for (var i = 1; i < segments.length; i += 1) {
+    e = pruneSegmentIntersection(segments[i - 1], segments[i]);
+    segments[i - 1] = e[0];
+    segments[i] = e[1];
+  }
+
+  if (segments.length > 1) {
+    e = pruneSegmentIntersection(segments[segments.length - 1], segments[0]);
+    segments[segments.length - 1] = e[0];
+    segments[0] = e[1];
+  }
+
+  return segments;
+}
+
+function OffsetPathModifier() {}
+
+extendPrototype([ShapeModifier], OffsetPathModifier);
+OffsetPathModifier.prototype.initModifierProperties = function (elem, data) {
+  this.getValue = this.processKeys;
+  this.amount = PropertyFactory.getProp(elem, data.a, 0, null, this);
+  this.miterLimit = PropertyFactory.getProp(elem, data.ml, 0, null, this);
+  this.lineJoin = data.lj;
+  this._isAnimated = this.amount.effectsSequence.length !== 0;
+};
+
+OffsetPathModifier.prototype.processPath = function (inputBezier, amount, lineJoin, miterLimit) {
+  var outputBezier = shapePool.newElement();
+  outputBezier.c = inputBezier.c;
+  var count = inputBezier.length();
+  if (!inputBezier.c) {
+    count -= 1;
+  }
+  var left; var right; var mid; var split;
+  var i; var j; var segment;
+  var multiSegments = [];
+
+  for (i = 0; i < count; i += 1) {
+    segment = PolynomialBezier.shapeSegment(inputBezier, i);
+    /*
+      We split each bezier segment into smaller pieces based
+      on inflection points, this ensures the control point
+      polygon is convex.
+
+      (A cubic bezier can have none, one, or two inflection points)
+    */
+    var flex = segment.inflectionPoints();
+
+    if (flex.length === 0) {
+      multiSegments.push([offsetSegment(segment, amount)]);
+    } else if (flex.length === 1 || floatEqual(flex[1], 1)) {
+      split = segment.split(flex[0]);
+      left = split[0];
+      right = split[1];
+
+      multiSegments.push([
+        offsetSegment(left, amount),
+        offsetSegment(right, amount),
+      ]);
+    } else {
+      split = segment.split(flex[0]);
+      left = split[0];
+      var t = (flex[1] - flex[0]) / (1 - flex[0]);
+      split = split[1].split(t);
+      mid = split[0];
+      right = split[1];
+
+      multiSegments.push([
+        offsetSegment(left, amount),
+        offsetSegment(mid, amount),
+        offsetSegment(right, amount),
+      ]);
+    }
+  }
+
+  multiSegments = pruneIntersections(multiSegments);
+
+  // Add bezier segments to the output and apply line joints
+  var lastPoint = null;
+  var lastSeg = null;
+
+  for (i = 0; i < multiSegments.length; i += 1) {
+    var multiSegment = multiSegments[i];
+
+    if (lastSeg) lastPoint = joinLines(outputBezier, lastSeg, multiSegment[0], lineJoin, miterLimit);
+
+    lastSeg = multiSegment[multiSegment.length - 1];
+
+    for (j = 0; j < multiSegment.length; j += 1) {
+      segment = multiSegment[j];
+
+      if (lastPoint && pointEqual(segment.points[0], lastPoint)) {
+        outputBezier.setXYAt(segment.points[1][0], segment.points[1][1], 'o', outputBezier.length() - 1);
+      } else {
+        outputBezier.setTripleAt(
+          segment.points[0][0],
+          segment.points[0][1],
+          segment.points[1][0],
+          segment.points[1][1],
+          segment.points[0][0],
+          segment.points[0][1],
+          outputBezier.length()
+        );
+      }
+
+      outputBezier.setTripleAt(
+        segment.points[3][0],
+        segment.points[3][1],
+        segment.points[3][0],
+        segment.points[3][1],
+        segment.points[2][0],
+        segment.points[2][1],
+        outputBezier.length()
+      );
+
+      lastPoint = segment.points[3];
+    }
+  }
+
+  if (inputBezier.c && multiSegments.length) joinLines(outputBezier, lastSeg, multiSegments[0][0], lineJoin, miterLimit);
+
+  return outputBezier;
+};
+
+OffsetPathModifier.prototype.processShapes = function (_isFirstFrame) {
+  var shapePaths;
+  var i;
+  var len = this.shapes.length;
+  var j;
+  var jLen;
+  var amount = this.amount.v;
+  var miterLimit = this.miterLimit.v;
+  var lineJoin = this.lineJoin;
+
+  if (amount !== 0) {
+    var shapeData;
+    var localShapeCollection;
+    for (i = 0; i < len; i += 1) {
+      shapeData = this.shapes[i];
+      localShapeCollection = shapeData.localShapeCollection;
+      if (!(!shapeData.shape._mdf && !this._mdf && !_isFirstFrame)) {
+        localShapeCollection.releaseShapes();
+        shapeData.shape._mdf = true;
+        shapePaths = shapeData.shape.paths.shapes;
+        jLen = shapeData.shape.paths._length;
+        for (j = 0; j < jLen; j += 1) {
+          localShapeCollection.addShape(this.processPath(shapePaths[j], amount, lineJoin, miterLimit));
+        }
+      }
+      shapeData.shape.paths = shapeData.localShapeCollection;
+    }
+  }
+  if (!this.dynamicProperties.length) {
+    this._mdf = false;
+  }
+};
+
+export default OffsetPathModifier;
diff --git a/player/js/utils/shapes/ShapePath.js b/player/js/utils/shapes/ShapePath.js
index 5b06a53..3e55287 100644
--- a/player/js/utils/shapes/ShapePath.js
+++ b/player/js/utils/shapes/ShapePath.js
@@ -93,4 +93,8 @@
   return newPath;
 };
 
+ShapePath.prototype.length = function () {
+  return this._length;
+};
+
 export default ShapePath;
diff --git a/player/js/utils/shapes/ZigZagModifier.js b/player/js/utils/shapes/ZigZagModifier.js
new file mode 100644
index 0000000..6afbb93
--- /dev/null
+++ b/player/js/utils/shapes/ZigZagModifier.js
@@ -0,0 +1,128 @@
+import {
+  extendPrototype,
+} from '../functionExtensions';
+import PropertyFactory from '../PropertyFactory';
+import shapePool from '../pooling/shape_pool';
+import {
+  ShapeModifier,
+} from './ShapeModifiers';
+import { PolynomialBezier } from '../PolynomialBezier';
+
+function ZigZagModifier() {}
+extendPrototype([ShapeModifier], ZigZagModifier);
+ZigZagModifier.prototype.initModifierProperties = function (elem, data) {
+  this.getValue = this.processKeys;
+  this.amplitude = PropertyFactory.getProp(elem, data.s, 0, null, this);
+  this.frequency = PropertyFactory.getProp(elem, data.r, 0, null, this);
+  this._isAnimated = this.amplitude.effectsSequence.length !== 0 && this.frequency.effectsSequence.length !== 0;
+};
+
+function angleMean(a, b) {
+  if (Math.abs(a - b) > Math.PI) return (a + b) / 2 + Math.PI;
+
+  return (a + b) / 2;
+}
+
+function zigZagCorner(outputBezier, segmentBefore, segmentAfter, amplitude, direction) {
+  var point;
+  var angle;
+
+  if (!segmentBefore) {
+    point = segmentAfter.points[0];
+    angle = segmentAfter.normalAngle(0);
+  } else if (!segmentAfter) {
+    point = segmentBefore.points[3];
+    angle = segmentBefore.normalAngle(1);
+  } else {
+    point = segmentAfter.points[0];
+    angle = angleMean(segmentAfter.normalAngle(0), segmentBefore.normalAngle(1));
+  }
+
+  var px = point[0] + Math.cos(angle) * direction * amplitude;
+  var py = point[1] - Math.sin(angle) * direction * amplitude;
+  outputBezier.setTripleAt(px, py, px, py, px, py, outputBezier.length());
+}
+
+function zigZagSegment(outputBezier, segment, amplitude, frequency, direction) {
+  for (var i = 0; i < frequency; i += 1) {
+    var t = (i + 1) / (frequency + 1);
+    var angle = segment.normalAngle(t);
+    var point = segment.point(t);
+    var px = point[0] + Math.cos(angle) * direction * amplitude;
+    var py = point[1] - Math.sin(angle) * direction * amplitude;
+
+    outputBezier.setTripleAt(px, py, px, py, px, py, outputBezier.length());
+
+    direction = -direction;
+  }
+
+  return direction;
+}
+
+ZigZagModifier.prototype.processPath = function (path, amplitude, frequency) {
+  var count = path._length;
+  var clonedPath = shapePool.newElement();
+  clonedPath.c = path.c;
+
+  if (!path.c) {
+    count -= 1;
+  }
+
+  if (count === 0) return clonedPath;
+
+  var direction = -1;
+  var segment = path.c ? PolynomialBezier.shapeSegment(path, count - 1) : null;
+  var nextSegment = PolynomialBezier.shapeSegment(path, 0);
+
+  zigZagCorner(clonedPath, segment, nextSegment, amplitude, -1);
+
+  for (var i = 0; i < count; i += 1) {
+    segment = nextSegment;
+
+    direction = zigZagSegment(clonedPath, segment, amplitude, frequency, -direction);
+
+    if (i === count - 1 && !path.c) {
+      nextSegment = null;
+    } else {
+      nextSegment = PolynomialBezier.shapeSegment(path, (i + 1) % count);
+    }
+
+    zigZagCorner(clonedPath, segment, nextSegment, amplitude, direction);
+  }
+
+  return clonedPath;
+};
+
+ZigZagModifier.prototype.processShapes = function (_isFirstFrame) {
+  var shapePaths;
+  var i;
+  var len = this.shapes.length;
+  var j;
+  var jLen;
+  var amplitude = this.amplitude.v;
+  var frequency = Math.max(0, Math.round(this.frequency.v));
+
+  if (amplitude !== 0) {
+    var shapeData;
+    var localShapeCollection;
+    for (i = 0; i < len; i += 1) {
+      shapeData = this.shapes[i];
+      localShapeCollection = shapeData.localShapeCollection;
+      if (!(!shapeData.shape._mdf && !this._mdf && !_isFirstFrame)) {
+        localShapeCollection.releaseShapes();
+        shapeData.shape._mdf = true;
+        shapePaths = shapeData.shape.paths.shapes;
+        jLen = shapeData.shape.paths._length;
+        for (j = 0; j < jLen; j += 1) {
+          localShapeCollection.addShape(this.processPath(shapePaths[j], amplitude, frequency));
+        }
+      }
+      shapeData.shape.paths = shapeData.localShapeCollection;
+    }
+  }
+  if (!this.dynamicProperties.length) {
+    this._mdf = false;
+  }
+};
+
+export default ZigZagModifier;