Skip to content

Commit

Permalink
Add function to convert arcs to curves
Browse files Browse the repository at this point in the history
  • Loading branch information
rlidwka committed Apr 15, 2015
1 parent 1185808 commit 5ebfb84
Show file tree
Hide file tree
Showing 3 changed files with 279 additions and 0 deletions.
186 changes: 186 additions & 0 deletions lib/a2c.js
@@ -0,0 +1,186 @@
// Convert an arc to a sequence of cubic bézier curves
//

'use strict';

var TAU = Math.PI * 2;

// causes an error in case of a long url in comments
/* eslint-disable max-len */

// spaces are used for grouping throughout this file
/* eslint-disable space-infix-ops */

// Calculate an angle between two vectors
//
function vector_angle(ux, uy, vx, vy) {
var sign = (ux * vy - uy * vx < 0) ? -1 : 1;
var umag = Math.sqrt(ux * ux + uy * uy);
var vmag = Math.sqrt(ux * ux + uy * uy);
var dot = ux * vx + uy * vy;
var div = dot / (umag * vmag);

if (div > 1 || div < -1) {
// rounding errors, e.g. -1.0000000000000002 can screw up this
div = Math.max(div, -1);
div = Math.min(div, 1);
}

return sign * Math.acos(div);
}

// Correction of out-of-range radii
//
function correct_radii(midx, midy, rx, ry) {
rx = Math.abs(rx);
ry = Math.abs(ry);

var Λ = (midx * midx) / (rx * rx) + (midy * midy) / (ry * ry);
if (Λ > 1) {
rx *= Math.sqrt(Λ);
ry *= Math.sqrt(Λ);
}

return [ rx, ry ];
}


// Convert from endpoint to center parameterization,
// see http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes
//
// Return [cx, cy, θ1, Δθ]
//
function get_arc_center(x1, y1, x2, y2, fa, fs, rx, ry, sin_φ, cos_φ) {
// Step 1.
//
// Moving an ellipse so origin will be the middlepoint between our two
// points. After that, rotate it to line up ellipse axes with coordinate
// axes.
//
var x1p = cos_φ*(x1-x2)/2 + sin_φ*(y1-y2)/2;
var y1p = -sin_φ*(x1-x2)/2 + cos_φ*(y1-y2)/2;

var rx_sq = rx * rx;
var ry_sq = ry * ry;
var x1p_sq = x1p * x1p;
var y1p_sq = y1p * y1p;

// Step 2.
//
// Compute coordinates of the centre of this ellipse (cx', cy')
// in the new coordinate system.
//
var radicant = (rx_sq * ry_sq) - (rx_sq * y1p_sq) - (ry_sq * x1p_sq);

if (radicant < 0) {
// due to rounding errors it might be e.g. -1.3877787807814457e-17
radicant = 0;
}

radicant /= (rx_sq * y1p_sq) + (ry_sq * x1p_sq);
radicant = Math.sqrt(radicant) * (fa === fs ? -1 : 1);

var cxp = radicant * rx/ry * y1p;
var cyp = radicant * -ry/rx * x1p;

// Step 3.
//
// Transform back to get centre coordinates (cx, cy) in the original
// coordinate system.
//
var cx = cos_φ*cxp - sin_φ*cyp + (x1+x2)/2;
var cy = sin_φ*cxp + cos_φ*cyp + (y1+y2)/2;

// Step 4.
//
// Compute angles (θ1, Δθ).
//
var v1x = (x1p - cxp) / rx;
var v1y = (y1p - cyp) / ry;
var v2x = (-x1p - cxp) / rx;
var v2y = (-y1p - cyp) / ry;

var θ1 = vector_angle(1, 0, v1x, v1y);
var Δθ = vector_angle(v1x, v1y, v2x, v2y);

if (fs === 0 && Δθ > 0) {
Δθ -= TAU;
}
if (fs === 1 && Δθ < 0) {
Δθ += TAU;
}

return [ cx, cy, θ1, Δθ ];
}

//
// Approximate one unit arc segment with bézier curves,
// see http://math.stackexchange.com/questions/873224/calculate-control-points-of-cubic-bezier-curve-approximating-a-part-of-a-circle
//
function approximate_unit_arc(θ1, Δθ) {
var α = 4/3 * Math.tan(Δθ/4);

var x1 = Math.cos(θ1);
var y1 = Math.sin(θ1);
var x2 = Math.cos(θ1 + Δθ);
var y2 = Math.sin(θ1 + Δθ);

return [ x1, y1, x1 - y1*α, y1 + x1*α, x2 + y2*α, y2 - x2*α, x2, y2 ];
}

module.exports = function a2c(x1, y1, x2, y2, fa, fs, rx, ry, φ) {
var sin_φ = Math.sin(φ * TAU / 360);
var cos_φ = Math.cos(φ * TAU / 360);

// Make sure radii are valid
//
var x1p = cos_φ*(x1-x2)/2 + sin_φ*(y1-y2)/2;
var y1p = -sin_φ*(x1-x2)/2 + cos_φ*(y1-y2)/2;

var radii = correct_radii(x1p, y1p, rx, ry);
rx = radii[0];
ry = radii[1];

// Get center parameters (cx, cy, θ1, Δθ)
//
var cc = get_arc_center(x1, y1, x2, y2, fa, fs, rx, ry, sin_φ, cos_φ);

var result = [];
var θ1 = cc[2];
var Δθ = cc[3];

// Split an arc to multiple segments, so each segment
// will be less than τ/4 (= 90°)
//
var segments = Math.ceil(Math.abs(Δθ) / (TAU / 4));
Δθ /= segments;

for (var i = 0; i < segments; i++) {
result.push(approximate_unit_arc(θ1, Δθ));
θ1 += Δθ;
}

// We have a bezier approximation of a unit circle,
// now need to transform back to the original ellipse
//
return result.map(function (curve) {
for (var i = 0; i < curve.length; i += 2) {
var x = curve[i + 0];
var y = curve[i + 1];

// scale
x *= rx;
y *= ry;

// rotate
var xp = cos_φ*x - sin_φ*y;
var yp = sin_φ*x + cos_φ*y;

// translate
curve[i + 0] = xp + cc[0];
curve[i + 1] = yp + cc[1];
}

return curve;
});
};
31 changes: 31 additions & 0 deletions lib/svgpath.js
Expand Up @@ -16,6 +16,7 @@
var pathParse = require('./path_parse');
var transformParse = require('./transform_parse');
var matrix = require('./matrix');
var a2c = require('./a2c');


// Class constructor
Expand Down Expand Up @@ -422,6 +423,36 @@ SvgPath.prototype.rel = function () {
};


// Converts arcs to cubic bézier curves
//
SvgPath.prototype.unarc = function () {
this.iterate(function (s, index, x, y) {
var i, new_segments, result = [], name = s[0];

// convert relative arc coordinates to absolute
if (name === 'a') {
name = 'A';
s = s.slice(0);
s[6] += x;
s[7] += y;
}

// Skip anything except arcs
if (name !== 'A') { return null; }

new_segments = a2c(x, y, s[6], s[7], s[4], s[5], s[1], s[2], s[3]);

new_segments.forEach(function (s) {
result.push([ 'C', s[2], s[3], s[4], s[5], s[6], s[7] ]);
});

return result;
});

return this;
};


// Converts smooth curves (with missed control point) to generic curves
//
SvgPath.prototype.unshort = function () {
Expand Down
62 changes: 62 additions & 0 deletions test/api.js
Expand Up @@ -364,4 +364,66 @@ describe('API', function () {
);
});
});


describe('unarc', function () {
it('almost complete arc gets expanded to 4 curves', function () {
assert.equal(
svgpath('M100 100 A30 50 0 1 1 110 110').unarc().round().toString(),
'M100 100C89 83 87 54 96 33 105 12 122 7 136 20 149 33 154 61 147 84 141 108 125 119 110 110'
);
});

it('small arc gets expanded to one curve', function () {
assert.equal(
svgpath('M100 100 a30 50 0 0 1 30 30').unarc().round().toString(),
'M100 100C113 98 125 110 130 130'
);
});

it('unarc a circle', function () {
assert.equal(
svgpath('M 100, 100 m -75, 0 a 75,75 0 1,0 150,0 a 75,75 0 1,0 -150,0').unarc().round().toString(),
'M100 100m-75 0C25 141 59 175 100 175 141 175 175 141 175 100 175 59 141 25 100 25 59 25 25 59 25 100'
);
});

it('rounding errors', function () {
// Coverage
//
// Due to rounding errors, with these exact arguments radicant
// will be -9.974659986866641e-17, causing Math.sqrt() of that to be NaN
//
assert.equal(
svgpath('M-0.5 0 A 0.09188163040671497 0.011583783896639943 0 0 1 0 0.5').unarc().round(5).toString(),
'M-0.5 0C0.59517-0.01741 1.59491 0.08041 1.73298 0.21848 1.87105 0.35655 1.09517 0.48259 0 0.5'
);
});

it('rounding errors #2', function () {
// Coverage
//
// Due to rounding errors this will compute Math.acos(-1.0000000000000002)
// and fail when calculating vector between angles
//
assert.equal(
svgpath('M-0.07467194809578359 -0.3862391309812665' +
'A1.2618792965076864 0.2013618852943182 90 0 1 -0.7558937461581081 -0.8010219619609416')
.unarc().round(5).toString(),

'M-0.07467-0.38624C-0.09295 0.79262-0.26026 1.65542-0.44838 1.54088' +
'-0.63649 1.42634-0.77417 0.37784-0.75589-0.80102'
);
});

it("we're already there", function () {
// Asked to draw a curve between a point and itself. According to spec,
// nothing shall be drawn in this case.
//
assert.equal(
svgpath('M100 100A123 456 90 0 1 100 100').unarc().round().toString(),
'M100 100'
);
});
});
});

0 comments on commit 5ebfb84

Please sign in to comment.