Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Significant error when approximating elliptical arcs with bezier curves on canvas with javascript

Tags:

javascript

svg

I'm trying to convert svg path to canvas in javascript, however it's really hard to map svg path elliptical arcs to canvas path. One of the ways is to approximate using multiple bezier curves.

I have successfully implemented the approximation of elliptical arcs with bezier curves however the approximation isn't very accurate.

My code:

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

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,200
ctx.moveTo(100,200)
// a25,100 -30 0,1 50,-25
var rx = 25, ry=100 ,phi =  -30 * Math.PI / 180, fa = 0, fs = 1, x = 100, y = 200, x1 = x + 50, y1 = y - 25;

var cps = generateBezierPoints(rx, ry, phi, fa, fs, x, y, x1, y1);

var limit = 4;

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()

With the result:

Elliptical arc and its approximation

The red line represents the svg path elliptical arc and the black line represents the approximation

How can I accurately draw any possible elliptical arc on canvas?

Update:

Forgot to mention the original source of the algorithm: https://mortoray.com/2017/02/16/rendering-an-svg-elliptical-arc-as-bezier-curves/

like image 226
Karan Jit Singh Avatar asked Oct 18 '22 12:10

Karan Jit Singh


1 Answers

So both bugs are simply:

  • n2 should be declare n2 = theta + delta;
  • The E and Ed functions should use rX rY rather than rx ry.

And that fixes everything. Though the original should have obviously opted to divide up the arcs into equal sized portions rather than pi/4 sized elements and then appending the remainder. Just find out how many parts it will need, then divide the range into that many parts of equal size, seems like a much more elegant solution, and because error goes up with length it would also be more accurate.

See: https://jsfiddle.net/Tatarize/4ro0Lm4u/ for working version.


It's not just off in that one respect it doesn't work most anywhere. You can see that depending on phi, it does a lot of variously bad things. It's actually shockingly good there. But, broken everywhere else too.

https://jsfiddle.net/Tatarize/dm7yqypb/

The reason is that the declaration of n2 is wrong and should read:

n2 = theta + delta;

https://jsfiddle.net/Tatarize/ba903pss/ But, fixing the bug in the indexing, it clearly does not scale up there like it should. It might be that arcs within the svg standard are scaled up so that there can certainly be a solution whereas in the relevant code they seem like they are clamped.

https://www.w3.org/TR/SVG/implnote.html#ArcOutOfRangeParameters

"If rx, ry and φ are such that there is no solution (basically, the ellipse is not big enough to reach from (x1, y1) to (x2, y2)) then the ellipse is scaled up uniformly until there is exactly one solution (until the ellipse is just big enough)."

Testing this, since it does properly have code that should scale it up, I changed it green when that code got called. And it turns green when it screws up. So yeah, it's failure to scale for some reason:

https://jsfiddle.net/Tatarize/tptroxho/

Which means something is using rx rather than the scaled rX and it's the E and Ed functions:

var enx = cx + rx * Math.cos(phi) * Math.cos(n) - ry * Math.sin(phi) * Math.sin(n);

These rx references must read rX and rY for ry.

var enx = cx + rX * Math.cos(phi) * Math.cos(n) - rY * Math.sin(phi) * Math.sin(n);

Which finally fixes the last bug, QED.

https://jsfiddle.net/Tatarize/4ro0Lm4u/


I got rid of the canvas, moved everything to svg and animated it.

var svgNS = "http://www.w3.org/2000/svg";
var svg = document.getElementById("svg");
var arcgroup = document.getElementById("arcgroup");
var curvegroup = document.getElementById("curvegroup");

function doArc() {
  while (arcgroup.firstChild) {
    arcgroup.removeChild(arcgroup.firstChild);
  } //clear old svg data. -->
  var d = document.createElementNS(svgNS, "path");
  //var path = "M100,200 a25,100 -30 0,1 50,-25"
  var path = "M" + x + "," + y + "a" + rx + " " + ry + " " + phi + " " + fa + " " + fs + " " + " " + x1 + " " + y1;
  d.setAttributeNS(null, "d", path);
  arcgroup.appendChild(d);
}

function doCurve() {
  var cps = generateBezierPoints(rx, ry, phi * Math.PI / 180, fa, fs, x, y, x + x1, y + y1);

  while (curvegroup.firstChild) {
    curvegroup.removeChild(curvegroup.firstChild);
  } //clear old svg data. -->
  var d = document.createElementNS(svgNS, "path");
  var limit = 4;
  var path = "M" + x + "," + y;
  for (var i = 0; i < limit && i < cps.length; i++) {
    if (i < limit - 1) {
      path += "C" + cps[i].cpx1 + " " + cps[i].cpy1 + " " + cps[i].cpx2 + " " + cps[i].cpy2 + " " + cps[i].en2.x + " " + cps[i].en2.y;
    } else {
      path += "C" + cps[i].cpx1 + " " + cps[i].cpy1 + " " + cps[i].cpx2 + " " + cps[i].cpy2 + " " + (x + x1) + " " + (y + y1);
    }
  }
  d.setAttributeNS(null, "d", path);
  d.setAttributeNS(null, "stroke", "#000");
  curvegroup.appendChild(d);
}

setInterval(phiClock, 50);

function phiClock() {
  phi += 1;
  doCurve();
  doArc();
}
doCurve();
doArc();
like image 73
Tatarize Avatar answered Oct 20 '22 22:10

Tatarize