javascriptsvgsnap.svgsvg-pathsvg-morphing

Rotating SVG path points for better morphing


I am using a couple of functions from Snap.SVG, mainly path2curve and the functions around it to build a SVG morph plugin.

I've setup a demo here on Codepen to better illustrate the issue. Basically morphing shapes simple to complex and the other way around is working properly as of Javascript functionality, however, the visual isn't very pleasing.

The first shape morph looks awful, the second looks a little better because I changed/rotated it's points a bit, but the last example is perfect.

So I need either a better path2curve or a function to prepare the path string before the other function builds the curves array. Snap.SVG has a function called getClosest that I think may be useful but it's not documented.

There isn't any documentation available on this topic so I would appreciate any suggestion/input from RaphaelJS / SnapSVG / d3.js / three/js developers.


Solution

  • I've provided a runnable code snippet below that uses Snap.svg and that I believe demonstrates one solution to your problem. With respect to trying to find the best way to morph a starting shape into an ending shape, this algorithm essentially rotates the points of the starting shape one position at a time, sums the squares of the distances between corresponding points on the (rotated) starting shape and the (unchanged) ending shape, and finds the minimum of all those sums. i.e. It's basically a least squares approach. The minimum value identifies the rotation that, as a first guess, will provide the "shortest" morph trajectories. In spite of these coordinate reassignments, however, all 'rotations' should result in visually identical starting shapes, as required.

    This is, of course, a "blind" mathematical approach, but it might help provide you with a starting point before doing manual visual analysis. As a bonus, even if you don't like the rotation that the algorithm chose, it also provides the path 'd' attribute strings for all the other rotations, so some of that work has already been done for you.

    You can modify the snippet to provide any starting and ending shapes you want. The limitations are as follows:

    By the way, in response to some of your comments, while I find Snap.svg intriguing, I also find its documentation to be somewhat lacking.

    Update: The code snippet below works in Firefox (Mac or Windows) and Safari. However, Chrome seems to have trouble accessing the Snap.svg library from its external web site as written (<script...github...>). Opera and Internet Explorer also have problems. So, try the snippet in the working browsers, or try copying the snippet code as well as the Snap library code to your own computer. (Is this an issue of accessing third party libraries from within the code snippet? And why browser differences? Insightful comments would be appreciated.)

    var
      s         = Snap(),
      colors    = ["red", "blue", "green", "orange"], // colour list can be any length
      staPath   = s.path("M25,35 l-15,-25 C35,20 25,0 40,0 L80,40Z"),  // create the "start" shape
      endPath   = s.path("M10,110 h30 l30,20 C30,120 35,135 25,135Z"), // create the "end"   shape
      staSegs   = getSegs(staPath), // convert the paths to absolute values, using only cubic bezier
      endSegs   = getSegs(endPath), //   segments, & extract the pt coordinates & segment strings
      numSegs   = staSegs.length,   // note: the # of pts is one less than the # of path segments
      numPts    = numSegs - 1,      //   b/c the path's initial 'moveto' pt is also the 'close' pt
      linePaths = [],
      minSumLensSqrd = Infinity,
      rotNumOfMin,
      rotNum = 0;
    
    document.querySelector('button').addEventListener('click', function() {
      if (rotNum < numPts) {
        linePaths.forEach(function(linePath) {linePath.remove();}); // erase any previous coloured lines
        var sumLensSqrd = 0;
        for (var ptNum = 0; ptNum < numPts; ptNum += 1) { // draw new lines, point-to-point
          var linePt1 = staSegs[(rotNum + ptNum) % numPts]; // the new line begins on the 'start' shape
          var linePt2 = endSegs[          ptNum  % numPts]; // and finished on the 'end' shape
          var linePathStr = "M" + linePt1.x + "," + linePt1.y + "L" + linePt2.x + "," + linePt2.y;
          var linePath = s.path(linePathStr).attr({stroke: colors[ptNum % colors.length]}); // draw it
          var lineLen = Snap.path.getTotalLength(linePath); // calculate its length
          sumLensSqrd += lineLen * lineLen; // square the length, and add it to the accumulating total
          linePaths[ptNum] = linePath; // remember the path to facilitate erasing it later
        }
        if (sumLensSqrd < minSumLensSqrd) { // keep track of which rotation has the lowest value
          minSumLensSqrd = sumLensSqrd;     //   of the sum of lengths squared (the 'lsq sum')
          rotNumOfMin = rotNum;             //   as well as the corresponding rotation number
        }
        show("ROTATION OF POINTS #" + rotNum + ":"); // display info about this rotation
        var rotInfo = getRotInfo(rotNum);
        show("&nbsp;&nbsp;point coordinates: " + rotInfo.ptsStr); // show point coordinates
        show("&nbsp;&nbsp;path 'd' string: " + rotInfo.dStr); // show 'd' string needed to draw it
        show("&nbsp;&nbsp;sum of (coloured line lengths squared) = " + sumLensSqrd); // the 'lsq sum'
        rotNum += 1; // analyze the next rotation of points
      } else { // once all the rotations have been analyzed individually...
        linePaths.forEach(function(linePath) {linePath.remove();}); // erase any coloured lines
        show("&nbsp;");
        show("BEST ROTATION, i.e. rotation with lowest sum of (lengths squared): #" + rotNumOfMin);
          // show which rotation to use
        show("Use the shape based on this rotation of points for morphing");
        $("button").off("click");
      }
    });
    
    function getSegs(path) {
      var absCubDStr = Snap.path.toCubic(Snap.path.toAbsolute(path.attr("d")));
      return Snap.parsePathString(absCubDStr).map(function(seg, segNum) {
        return {x: seg[segNum ? 5 : 1], y: seg[segNum ? 6 : 2], seg: seg.toString()};
      });
    }
    
    function getRotInfo(rotNum) {
      var ptsStr = "";
      for (var segNum = 0; segNum < numSegs; segNum += 1) {
        var oldSegNum = rotNum + segNum;
        if (segNum === 0) {
          var dStr = "M" + staSegs[oldSegNum].x + "," + staSegs[oldSegNum].y;
        } else {
          if (oldSegNum >= numSegs) oldSegNum -= numPts;
          dStr += staSegs[oldSegNum].seg;
        }
        if (segNum !== (numSegs - 1)) {
          ptsStr += "(" + staSegs[oldSegNum].x + "," + staSegs[oldSegNum].y + "), ";
        }
      }
      ptsStr = ptsStr.slice(0, ptsStr.length - 2);
      return {ptsStr: ptsStr, dStr: dStr};
    }
    
    function show(msg) {
      var m = document.createElement('pre');
      m.innerHTML = msg;
      document.body.appendChild(m);
    }
    pre {
      margin: 0;
      padding: 0;
    }
    <script src="//cdn.jsdelivr.net/snap.svg/0.4.1/snap.svg-min.js"></script>
    <p>Best viewed on full page</p>
    <p>Coloured lines show morph trajectories for the points for that particular rotation of points. The algorithm seeks to optimize those trajectories, essentially trying to find the "shortest" cumulative routes.</p>
    <p>The order of points can be seen by following the colour of the lines: red, blue, green, orange (at least when this was originally written), repeating if there are more than 4 points.</p>
    <p><button>Click to show rotation of points on top shape</button></p>