Envelope Distortion using C# and GraphicsPath Class

envelope-distort-graphic-small

I asked this question on StackOverflow nearly two years ago.  That is, looking for an algorithm to distort an arbitrary set of polygons (text for example) into a predetermined shape.  If you are familiar with Adobe Illustrator this feature is called Envelope Distortion.   Surprisingly my SO post got little response and I ultimately followed up with a limited workaround, although not a solution. I really wanted to incorporate this feature into my upcoming api, so I set out to figure it out myself.  Since there is not a lot of information about this on the net (that I can find) I decided to share a portion of my work. Step one was printing out some grid paper and figuring out exactly how this process should work. In this case writing code wasn’t going get anywhere until the underlying process was solved.  Essentially the algorithm should accept any X, Y inside of the bounds of the source polygon and return a transformed X, Y that fits inside of the resulting distorted polygon.

Implementing Bulge Distortion

Bulge distortion creates a bulging effect on a path by stretching it up and down.  This is useful since several other types of distortions can be easily achieved by making slight modifications to the generated distortion path.

bulge-distortion-1

Shown above is some arbitrary text loaded into a GDI+ GraphicsPath object.  Also drawn around the text is a distortion path that we will ultimately warp the text into.  The code accepts an float called Intensity which can be positive or negative.  This is a positive intensity of 1, should you use a negative intensity the distortion path would look like below.

bulge-distortion-2

In order to debug the process of writing the bulge distortion my first task was to view a grid of each point remapped into the correct location.  Upon this process it should be relatively easy to feed in a flattened Polygon (no bezier curves, just points) and see the resulting transformation.

bulge-distortion-3

How it works

For example, if we wanted to know where would X=170 and Y=10 translate to given the above Bulge distortion with Intensity = 1?  Refer the image below, first we calculate the relative position of Y in the bounding box.  We then scale that position linear to the relative height of the distortion path.  The difficult part is that should we ask for an X that is not in the flattened distortion path we have to calculate the location of that point on any given polygon.  This seems quite easy but in fact was not.  My solution was to utilize the outstanding polygon clipping implementation called Clipper.

bulge-distortion-4

In order to get the upper and lower bound of the Distortion Path given an source X, you need to essentially subtract the shaded polygon from the distortion path. At that point the resulting polygon read in the upper left and lower left points to calculate the height range. These two methods implement the above

        public PointF Distort(GraphicsPath source, PointF point)
        {
            if (_distortionPath == null)
            {
                BuildDistortion(source);
            }
 
            var ScaledPoint = point;
 
            PointF UpperBoundPoint;
            PointF LowerBoundPoint;
 
            GetBoundedPoints(out UpperBoundPoint, out LowerBoundPoint, point);
            var Y = UpperBoundPoint.Y + (((ScaledPoint.Y - _sourceBounds.Top) / _sourceBounds.Height) * Math.Abs(UpperBoundPoint.Y - LowerBoundPoint.Y));
 
            return new PointF(ScaledPoint.X, Y);
        }
 
        private void GetBoundedPoints(out PointF upperBoundPoint, out PointF lowerBoundPoint, PointF source)
        {
 
            if (_boundCache.ContainsKey(source.X))
            {
                upperBoundPoint = _boundCache[source.X][0];
                lowerBoundPoint = _boundCache[source.X][1];
                return;
            }
 
            var Path = new GraphicsPath();
            var UpperX = source.X * (_sourceBounds.Width / (_upperRight.X -_upperLeft.X));
            var LowerX = source.X * (_sourceBounds.Width / (_lowerRight.X -_lowerLeft.X));
            Path.AddPolygon(new PointF[]{
                new PointF(_distortionBounds.Left,_distortionBounds.Bottom),
                new PointF(_distortionBounds.Left, _distortionBounds.Top),
                new PointF(UpperX,  _distortionBounds.Top),
                new PointF(LowerX, _distortionBounds.Bottom), 
            });
            Path.CloseFigure();
            var ClippingPath = ClipperUtility.ConvertToClipperPolygons(Path);
            Path.Dispose();
 
            var ClippedPath = ClipperUtility.Clip(ClippingPath, _distortionPoints);
            if (Math.Abs(source.X - _sourceBounds.Left) < .1 || Math.Abs(source.X - _sourceBounds.Right) < .1)             {                 upperBoundPoint = new PointF(_sourceBounds.Left, _sourceBounds.Top);                 lowerBoundPoint = new PointF(_sourceBounds.Left, _sourceBounds.Bottom );             }             else             {                 var Points = ClippedPath.PathPoints;                 var QuickBounded = Points.Where(p => Math.Abs(p.X - LowerX) < .01);                 if (QuickBounded.Any())                 {                     upperBoundPoint = Points.Where(p => Math.Abs(p.X - LowerX) < .01).OrderBy(p => p.Y).First();
                    lowerBoundPoint = Points.Where(p => Math.Abs(p.X - LowerX) < .01).OrderByDescending(p => p.Y).First();
                    _boundCache.Add(source.X, new PointF[] { upperBoundPoint, lowerBoundPoint });
                }
                else
                {
                    var RightMostPoints = Points.OrderByDescending(p => p.X).Take(2).ToList();
                    upperBoundPoint = RightMostPoints.OrderBy(p => p.Y).First();
                    lowerBoundPoint = RightMostPoints.OrderByDescending(p => p.Y).First();
                }
                ClippedPath.Dispose();
            }
 
        }

The GetBoundedPoints method generates the shaded polygon and calls  ClipperUtility.Clip(ClippingPath, _distortionPoints) which actually clips and generates the resulting polygon.  The last if statement handles the edge case of the X=0 or X= Width else it looks through the points for the closest to the edge.     You’ll probably the notice the _boundCache, that saves some work since once the upper and lower points are calculated for bulge distortion they are the same no matter what Y.  For that reason we cache them.  My particular complaint with this code in the state it is in is the floating point precision comparison.  You’ll notice a particularly sloppy Math.Abs(source.X – _sourceBounds.Left) < .1, Yikes!   While this code does perform perfectly with the test data I’ve tried so far, I believe that will be a point of failure first.  My thought is that there is a precision loss/gain during the polygon clipping procedure I need to investigate more.   At any rate, the resulting transformed text will look like this.

 bulge-distortion-5

Not bad for a first attempt, in fact everything looks great except the L and the right e.  Why is that?  Actually the algorithm is working perfectly, the issue is based on the process of flattening the path.  The L only has two lower points, the point on the bottom left, and the point on the bottom right.  Therefore, the algorithm did exactly what is was supposed to, move those two points into their respective distorted positions.  What we really need to is to inject points in between to increase the precision of the operation.

bulge-distortion-6

This code checks each path both vertical and horizontal runs, if the span is less than the flatness it injects points to increase the precision.

        private void InjectPrecisionPoints(GraphicsPath gp)
        {
            var InsertDictionary = new Dictionary&lt;int, PointF[]&gt;();
            //inject points on vertical and horizontal runs to increase precision
            for (var j = 0; j &lt; gp.PointCount; j++)
            {
                PointF CurrentPoint;
                PointF NextPoint;
                if (j != gp.PointCount - 1)
                {
                    CurrentPoint = gp.PathPoints[j];
                    NextPoint = gp.PathPoints[j + 1];
                }
                else
                {
                    CurrentPoint = gp.PathPoints[j];
                    NextPoint = gp.PathPoints[0];
                }
                if (Math.Abs(CurrentPoint.X - NextPoint.X) &lt; .001 &amp;&amp; Math.Abs(CurrentPoint.Y - NextPoint.Y) &gt; _flatness)
                {
                    var Distance = CurrentPoint.Y - NextPoint.Y;
                    var Items = Enumerable.Range(1, Convert.ToInt32(Math.Floor(Math.Abs(Distance)/_flatness)))
                                           .Select(p =&gt; new PointF(CurrentPoint.X, Distance &lt; 0 ? (CurrentPoint.Y + (_flatness * p)) : (CurrentPoint.Y - (_flatness * p))))
                                           .ToArray();
                    InsertDictionary.Add(j + 1, Items);
                }
                if (Math.Abs(CurrentPoint.Y - NextPoint.Y) &lt; .001 &amp;&amp; Math.Abs(CurrentPoint.X - NextPoint.X) &gt; _flatness)
                {
                    var Distance = CurrentPoint.X - NextPoint.X;
                    var Items =  Enumerable.Range(1, Convert.ToInt32(Math.Floor(Math.Abs(Distance)/_flatness)))
                                           .Select(p =&gt; new PointF(Distance &lt; 0 ? (CurrentPoint.X + (_flatness * p)) : (CurrentPoint.X - (_flatness * p)), CurrentPoint.Y))                                            .ToArray();                     InsertDictionary.Add(j + 1, Items);                 }             }             if (InsertDictionary.Count &gt; 0)
            {
                var PointArray = gp.PathPoints.ToList();
                InsertDictionary.OrderByDescending(p =&gt; p.Key).ToList().ForEach(p =&gt; PointArray.InsertRange(p.Key, p.Value));
 
                gp.Reset();
                gp.AddPolygon(PointArray.ToArray());
 
                InsertDictionary.Clear();
            }
        }

The resulting warp with the precision points injected

lone-techie-envelope-distort

The same Bulge Distortion with an Intensity of -.2

bulge-distortion-7

Implementing other distortions

distort-shapes

 

With relatively little change to the code you can also implement any of the above distortions just by modifying the BuildDistortion method.  The project is setup in such a way you could create a new class that implements IDistortion and create your own distortions.

Enjoy!

Here is the source code available on GitHub

Be Sociable, Share!
Tagged: , , , , ,

Discussion

  1. Cyril says:

    This is a really nice contribution, thanks a lot!

Add a Comment

*