javascriptsvgcubic-bezier

SVG: reverse cubic and quadratic bezier curves


I'm working on a script to reverse the draw direction of SVG path commands, everything is working smoothly so far but not with S path commands or T.

In one of my implementations for path reverse functionality based only on cubic bezier C curves, works flawlessly, however the output path string is considerably larger, sometimes double or triple the length.

Here's a simplified version of the reversePath.js which, so far, implements some basic handling for S and a test page:

  // the script I'm working on works with these arrays
  var path = [['M',10,80],['C',40, 10, 65, 10, 95, 80],['S',150,150,180,80],['S',230,10,270,80]],
      target = document.getElementById('target');

// our focus is RIGHT HERE
function reversePath(pathInput){
  let isClosed = pathInput.slice(-1)[0][0] === 'Z',
      params = {x1: 0, y1: 0, x2: 0, y2: 0, x: 0, y: 0, qx: null, qy: null},
      pathCommand = '', pLen = 0, 
      reversedPath = [];

  reversedPath = pathInput.map((seg,i,pathArray)=>{
    pLen = pathArray.length
    pathCommand = seg[0]

    switch(pathCommand){
      case 'M':
        x = seg[1]
        y = seg[2]
        break
      case 'Z':
        x = pathArray[0][1]
        y = pathArray[0][2]
        break
      default:
        x = seg[seg.length - 2]
        y = seg[seg.length - 1]
    }

    return {
      c: pathCommand, 
      x: x,
      y: y,
      seg: seg
    }
  }).map((seg,i,pathArray)=>{
    let segment = seg.seg,
        prevSeg = i && pathArray[i-1],
        nextSeg = pathArray[i+1] && pathArray[i+1],
        result = []

    pLen = pathArray.length
    pathCommand = seg.c
    
    params.x = i ? pathArray[i-1].x : pathArray[pLen-1].x
    params.y = i ? pathArray[i-1].y : pathArray[pLen-1].y

    switch(pathCommand){
      case 'M':
        result = isClosed ? ['Z'] : [pathCommand, params.x,params.y]
        break
      case 'C':
        if ('S' === nextSeg.c) {
          params.x2 = params.x1 + params.x2 / 2
          params.y2 = params.y1 + params.y2 / 2
          result = ['S', params.x2,params.y2, params.x,params.y]
        } else {
          params.x1 = segment[3]
          params.y1 = segment[4]
          params.x2 = segment[1]
          params.y2 = segment[2]
          result = [pathCommand, params.x1,params.y1, params.x2,params.y2, params.x,params.y];
        }
        break
      case 'S':
        params.x2 = params.x1 + params.x2 / 2
        params.y2 = params.y1 + params.y2 / 2

        if (nextSeg && 'S' === nextSeg.c) {
          result = [pathCommand, params.x2,params.y2, params.x,params.y]
        } else {
          params.x1 = params.x1 + params.x2 / 2
          params.y1 = params.y1 + params.y2 / 2
          params.x2 = segment[1];
          params.y2 = segment[2];
          result = ['C', params.x1,params.y1, params.x2,params.y2, params.x,params.y];
        }
        break
      case 'Z':
        result = ['M',params.x,params.y]
        break
      default:
        result = segment.slice(0,-2).concat([params.x,params.y])
    }    
    return result
  })
  return isClosed ? reversedPath.reverse() : [reversedPath[0]].concat(reversedPath.slice(1).reverse())
}

function pathToString(pathArray) {
  return pathArray.map(x=>x[0].concat(x.slice(1).join(' '))).join(' ')
}

function reverse(){
  var reversed = pathToString(reversePath(path));
  target.setAttribute('d',reversed)
  target.closest('.col').innerHTML += '<br><p class="text-left">'+reversed+'</p>'
}
.row {width: 100%; display: flex; flex-direction: row}
.col {width: 50%; text-align: center}
.text-left {text-align: left}
<button onclick="reverse()">REVERSE</button>
<hr>
<div class="row">
  <div class="col">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 270 160">
      <path id="example" d="M10 80 C40 10, 65 10, 95 80S 150 150, 180 80S 230 10 270 80" stroke="green" stroke-width="2" fill="transparent" />
    </svg>
    normal path
  </div>
  
  <div class="col">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 270 160">
      <path id="target" d="M0 0L0 0" stroke="orange" stroke-width="2" fill="transparent" />
    </svg>
    reversed path (click the button)
  </div>
</div>

I started here from the the Raphael.js implementation on converting S and Q and T path commands to C (cubicBezier), thinking and reverse engineering perhaps I could find a way to make it work.

So I need a little help figuring out a correct formula for these S and T path commands when reversing the shape. If someone can help me with S I can figure out myself on T.

Thanks for any reply.


Solution

  • Alright, the normalization did the trick. Also some new additions and more powerful value processing, but let's get to it, here's the updated function with new additions:

    // the script I'm working on works with these arrays
      var pathCubic = [['M',10,80],['C',40,10,65,10,95,80],['S',150,150,180,80],['S',230,10,270,80]],
          targetCubic = document.getElementById('target');
    
    // the updated function
    function reversePath(absolutePath){
      var isClosed = absolutePath.slice(-1)[0][0] === 'Z',
      reversedPath = normalizePath(absolutePath).map(function (segment,i){
        return {
          c: absolutePath[i][0],
          x: segment[segment.length - 2],
          y: segment[segment.length - 1],
          seg: absolutePath[i],
          normalized: segment
        }
      }).map(function (seg,i,pathArray){
        var segment = seg.seg,
            data = seg.normalized,
            prevSeg = i && pathArray[i-1],
            nextSeg = pathArray[i+1] && pathArray[i+1],
            pathCommand = seg.c,
            pLen = pathArray.length,
            x = i ? pathArray[i-1].x : pathArray[pLen-1].x,
            y = i ? pathArray[i-1].y : pathArray[pLen-1].y,
            result = [];
        switch(pathCommand){
          case 'M':
            result = isClosed ? ['Z'] : [pathCommand, x,y];
            break
          case 'C':
            if (nextSeg && nextSeg.c === 'S') {
              result = ['S', segment[1],segment[2], x,y];
            } else {
              result = [pathCommand, segment[3],segment[4], segment[1],segment[2], x,y];
            }
            break
          case 'S':
            if ( prevSeg && 'CS'.indexOf(prevSeg.c)>-1 && (!nextSeg || nextSeg && nextSeg.c !== 'S')) {
              result = ['C', data[3],data[4], data[1],data[2], x,y];
            } else {
              result = [pathCommand, data[1],data[2], x,y];
            }
            break
          case 'Z':
            result = ['M',x,y];
            break
        }
        return result
      });
      return isClosed ? reversedPath.reverse() : [reversedPath[0]].concat(reversedPath.slice(1).reverse())
    }
    
    // new additions
    function shorthandToCubic(x1,y1,x2,y2,prevCommand){
      return 'CS'.indexOf(prevCommand)>-1 ? { x1: x1 * 2 - x2, y1: y1 * 2 - y2}
                                          : { x1 : x1, y1 : y1 }
    }
    
    function normalizeSegment(segment, params, prevCommand) {
      var nqxy, nxy;
      switch (segment[0]) {
        case "S":
          nxy = shorthandToCubic(params.x1,params.y1, params.x2,params.y2, prevCommand);
          params.x1 = nxy.x1;
          params.y1 = nxy.y1;
          segment = ["C", nxy.x1, nxy.y1].concat(segment.slice(1));
          break
      }
      return segment
    }
    
    function normalizePath(pathArray) {
      var params = {x1: 0, y1: 0, x2: 0, y2: 0, x: 0, y: 0, qx: null, qy: null},
          allPathCommands = [], pathCommand = '', prevCommand = '', ii = pathArray.length,
          segment, seglen;
      for (var i = 0; i < ii; i++) {
        pathArray[i] && (pathCommand = pathArray[i][0]);
        allPathCommands[i] = pathCommand;
        i && ( prevCommand = allPathCommands[i - 1]);
        pathArray[i] = normalizeSegment(pathArray[i], params, prevCommand);
        segment = pathArray[i];
        seglen = segment.length;
        params.x1 = +segment[seglen - 2];
        params.y1 = +segment[seglen - 1];
        params.x2 = +(segment[seglen - 4]) || params.x1;
        params.y2 = +(segment[seglen - 3]) || params.y1;
      }
      return pathArray
    }
    
    function pathToString(pathArray) {
      return pathArray.map(x=>x[0].concat(x.slice(1).join(' '))).join(' ')
    }
    
    function reverse(){
      var reversedCubic = pathToString(reversePath(pathCubic));
      targetCubic.setAttribute('d',reversedCubic)
      targetCubic.closest('.col').innerHTML += '<br><p class="text-left">'+reversedCubic+'</p>'
    }
    .row {width: 100%; display: flex; flex-direction: row}
    .col {width: 50%; text-align: center}
    .text-left {text-align: left}
    <button onclick="reverse()">REVERSE</button> Now works with multiple `S` shorthands
    <hr>
    <div class="row">
      <div class="col">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 270 160">
          <path id="example" d="M10 80 C40 10, 65 10, 95 80S 150 150, 180 80S 230 10 270 80" stroke="green" stroke-width="2" fill="transparent" />
        </svg>
        normal path
      </div>
      
      <div class="col">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 270 160">
          <path id="target" d="M0 0L0 0" stroke="orange" stroke-width="2" fill="transparent" />
        </svg>
        reversed CUBIC BEZIER path
      </div>
    </div>

    You can check the latest SVGPathCommander version on npm or the demo page. It can also manage multiple T path commands, noo problem.