Search code examples
javascriptcanvassvgbeziercurve

Approximating svg elliptical arc in canvas with javascript


I'm trying to generate an elliptical arc by approximating a bezier curve as in the post https://mortoray.com/2017/02/16/rendering-an-svg-elliptical-arc-as-bezier-curves/

However my implementation doesn't seem to fetch the right result. (Red line is SVG and black line is canvas path)

This is my code

var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");

// M100,350
// a45,35 -30 0,1 50,-25

canvas.width = document.body.clientWidth;
canvas.height = document.body.clientHeight;
ctx.strokeWidth = 2;
ctx.strokeStyle = "#000000";

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max)
}

function svgAngle(ux, uy, vx, vy ) {
  var dot = ux*vx + uy*vy;
  var len = Math.sqrt(ux*ux + uy*uy) * Math.sqrt(vx*vx + vy*vy);

  var ang = Math.acos( clamp(dot / len,-1,1) );
  if ( (ux*vy - uy*vx) < 0)
    ang = -ang;
  return ang;
}

function generateBezierPoints(rx, ry, phi, flagA, flagS, x1, y1, x2, y2) {
  var rX = Math.abs(rx);
  var rY = Math.abs(ry);

  var dx2 = (x1 - x2)/2;
  var dy2 = (y1 - y2)/2;

  var x1p =  Math.cos(phi)*dx2 + Math.sin(phi)*dy2;
  var y1p = -Math.sin(phi)*dx2 + Math.cos(phi)*dy2;

  var rxs = rX * rX;
  var rys = rY * rY;
  var x1ps = x1p * x1p;
  var y1ps = y1p * y1p;

  var cr = x1ps/rxs + y1ps/rys;
  if (cr > 1) {
    var s = Math.sqrt(cr);
    rX = s * rX;
    rY = s * rY;
    rxs = rX * rX;
    rys = rY * rY;
  }

  var dq = (rxs * y1ps + rys * x1ps);
  var pq = (rxs*rys - dq) / dq;
  var q = Math.sqrt( Math.max(0,pq) );
  if (flagA === flagS)
    q = -q;
  var cxp = q * rX * y1p / rY;
  var cyp = - q * rY * x1p / rX;

  var cx = Math.cos(phi)*cxp - Math.sin(phi)*cyp + (x1 + x2)/2;
  var cy = Math.sin(phi)*cxp + Math.cos(phi)*cyp + (y1 + y2)/2;

  var theta = svgAngle( 1,0, (x1p-cxp) / rX, (y1p - cyp)/rY );

  var delta = svgAngle(
    (x1p - cxp)/rX, (y1p - cyp)/rY,
    (-x1p - cxp)/rX, (-y1p-cyp)/rY);

  delta = delta - Math.PI * 2 * Math.floor(delta / (Math.PI * 2));

  if (!flagS)
    delta -= 2 * Math.PI;

  var n1 = theta, n2 = delta;


  // E(n)
  // cx +acosθcosη−bsinθsinη
  // cy +asinθcosη+bcosθsinη
  function E(n) {
    var enx = cx + rx * Math.cos(phi) * Math.cos(n) - ry * Math.sin(phi) * Math.sin(n);
    var eny = cy + rx * Math.sin(phi) * Math.cos(n) + ry * Math.cos(phi) * Math.sin(n);
    return {x: enx,y: eny};
  }

  // E'(n)
  // −acosθsinη−bsinθcosη
  // −asinθsinη+bcosθcosη
  function Ed(n) {
    var ednx = -1 * rx * Math.cos(phi) * Math.sin(n) - ry * Math.sin(phi) * Math.cos(n);
    var edny = -1 * rx * Math.sin(phi) * Math.sin(n) + ry * Math.cos(phi) * Math.cos(n);
    return {x: ednx, y: edny};
  }

  var en1 = E(n1);
  var en2 = E(n2);
  var edn1 = Ed(n1);
  var edn2 = Ed(n2);

  var alpha = Math.sin(n2 - n1) * (Math.sqrt(4 + 3 * Math.pow(Math.tan((n2 - n1)/2), 2)) - 1)/3;

  console.log(en1, en2);

  return {
    cpx1: en1.x + alpha*edn1.x,
    cpy1: en1.y + alpha*edn1.y,
    cpx2: en2.x - alpha*edn2.x,
    cpy2: en2.y - alpha*edn2.y
  };
}

// M100,100
ctx.moveTo(100,100)
// a45,35 -30 0,1 50,-25
cp = generateBezierPoints(
  45,35,                            // Radii
  -30 * Math.PI / 180,              // xAngle
  0,                                // Large arc flag
  1,                                // Sweep flag
  100,100,                          // Endpoint1
  100 + 50, 100 - 25                // Endpoint2
  );
ctx.bezierCurveTo(cp.cpx1,cp.cpy1,cp.cpx2,cp.cpy2,150,75);
ctx.stroke()

I need help with understanding where I'm going wrong

UPDATE:

I went through the post a couple more times and there is one part of the post that I don't quite understand which may also be lacking in my implementation.

All I had to do was subdivide the angle range into small sections to get a good approximation. I didn’t quite understand the paper’s error calculations, but I found another paper by Joe Cridge indicating divisions of π/2 provides a potential one pixel error on a fairly high resolution device. So I chose π/4 to ensure smooth animation, even for partial arcs on high density mobile devices.

I don't understand what the author means by subdividing the angles...


Solution

  • So apparently an elliptical arc cannot be approximated with a single bezier curve, so it takes multiple bezier curves by dividing the two angles into ranges.

    var canvas = document.getElementById("canvas");
    var ctx = canvas.getContext("2d");
    
    // M100,350
    // a45,35 -30 0,1 50,-25
    
    canvas.width = document.body.clientWidth;
    canvas.height = document.body.clientHeight;
    ctx.strokeWidth = 2;
    ctx.strokeStyle = "#000000";
    function clamp(value, min, max) {
      return Math.min(Math.max(value, min), max)
    }
    
    function svgAngle(ux, uy, vx, vy ) {
      var dot = ux*vx + uy*vy;
      var len = Math.sqrt(ux*ux + uy*uy) * Math.sqrt(vx*vx + vy*vy);
    
      var ang = Math.acos( clamp(dot / len,-1,1) );
      if ( (ux*vy - uy*vx) < 0)
        ang = -ang;
      return ang;
    }
    
    function generateBezierPoints(rx, ry, phi, flagA, flagS, x1, y1, x2, y2) {
      var rX = Math.abs(rx);
      var rY = Math.abs(ry);
    
      var dx2 = (x1 - x2)/2;
      var dy2 = (y1 - y2)/2;
    
      var x1p =  Math.cos(phi)*dx2 + Math.sin(phi)*dy2;
      var y1p = -Math.sin(phi)*dx2 + Math.cos(phi)*dy2;
    
      var rxs = rX * rX;
      var rys = rY * rY;
      var x1ps = x1p * x1p;
      var y1ps = y1p * y1p;
    
      var cr = x1ps/rxs + y1ps/rys;
      if (cr > 1) {
        var s = Math.sqrt(cr);
        rX = s * rX;
        rY = s * rY;
        rxs = rX * rX;
        rys = rY * rY;
      }
    
      var dq = (rxs * y1ps + rys * x1ps);
      var pq = (rxs*rys - dq) / dq;
      var q = Math.sqrt( Math.max(0,pq) );
      if (flagA === flagS)
        q = -q;
      var cxp = q * rX * y1p / rY;
      var cyp = - q * rY * x1p / rX;
    
      var cx = Math.cos(phi)*cxp - Math.sin(phi)*cyp + (x1 + x2)/2;
      var cy = Math.sin(phi)*cxp + Math.cos(phi)*cyp + (y1 + y2)/2;
    
      var theta = svgAngle( 1,0, (x1p-cxp) / rX, (y1p - cyp)/rY );
    
      var delta = svgAngle(
        (x1p - cxp)/rX, (y1p - cyp)/rY,
        (-x1p - cxp)/rX, (-y1p-cyp)/rY);
    
      delta = delta - Math.PI * 2 * Math.floor(delta / (Math.PI * 2));
    
      if (!flagS)
        delta -= 2 * Math.PI;
    
      var n1 = theta, n2 = delta;
    
    
      // E(n)
      // cx +acosθcosη−bsinθsinη
      // cy +asinθcosη+bcosθsinη
      function E(n) {
        var enx = cx + rx * Math.cos(phi) * Math.cos(n) - ry * Math.sin(phi) * Math.sin(n);
        var eny = cy + rx * Math.sin(phi) * Math.cos(n) + ry * Math.cos(phi) * Math.sin(n);
        return {x: enx,y: eny};
      }
    
      // E'(n)
      // −acosθsinη−bsinθcosη
      // −asinθsinη+bcosθcosη
      function Ed(n) {
        var ednx = -1 * rx * Math.cos(phi) * Math.sin(n) - ry * Math.sin(phi) * Math.cos(n);
        var edny = -1 * rx * Math.sin(phi) * Math.sin(n) + ry * Math.cos(phi) * Math.cos(n);
        return {x: ednx, y: edny};
      }
    
      var n = [];
      n.push(n1);
    
      var interval = Math.PI/4;
    
      while(n[n.length - 1] + interval < n2)
        n.push(n[n.length - 1] + interval)
    
      n.push(n2);
    
      function getCP(n1, n2) {
        var en1 = E(n1);
        var en2 = E(n2);
        var edn1 = Ed(n1);
        var edn2 = Ed(n2);
    
        var alpha = Math.sin(n2 - n1) * (Math.sqrt(4 + 3 * Math.pow(Math.tan((n2 - n1)/2), 2)) - 1)/3;
    
        console.log(en1, en2);
    
        return {
          cpx1: en1.x + alpha*edn1.x,
          cpy1: en1.y + alpha*edn1.y,
          cpx2: en2.x - alpha*edn2.x,
          cpy2: en2.y - alpha*edn2.y,
          en1: en1,
          en2: en2
        };
      }
    
      var cps = []
      for(var i = 0; i < n.length - 1; i++) {
        cps.push(getCP(n[i],n[i+1]));
      }
    
    
      return cps;
    }
    
    // M100,100
    ctx.moveTo(100,100)
    // a45,35 -30 0,1 50,-25
    var rx = 45, ry=35,phi =  -30 * Math.PI / 180, fa = 0, fs = 1, x = 100, y = 100, x1 = x + 50, y1 = y - 25;
    
      var cps = generateBezierPoints(rx, ry, phi, fa, fs, x, y, x1, y1);
    
      var limit = 2;
    
      for(var i = 0; i < limit && i < cps.length; i++) {
        ctx.bezierCurveTo(cps[i].cpx1, cps[i].cpy1,
                          cps[i].cpx2, cps[i].cpy2,
                          i < limit - 1 ? cps[i].en2.x : x1, i < limit - 1 ? cps[i].en2.y : y1);
      }
    ctx.stroke()