Skip to content

Commit

Permalink
Support transforming arc commands
Browse files Browse the repository at this point in the history
  • Loading branch information
rhendric committed Jan 11, 2016
1 parent bdc6fc9 commit b4cbed7
Show file tree
Hide file tree
Showing 2 changed files with 136 additions and 1 deletion.
67 changes: 66 additions & 1 deletion src/SVGPathDataTransformer.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,77 @@ SVGPathDataTransformer.MATRIX = function matrixGenerator(a, b, c, d, e, f) {
command.y2 = origX2 * b + command.y2 * d +
(command.relative && 'undefined' !== typeof prevY ? 0 : f);
}
function sq(x) { return x*x; }
var det = a*d - b*c;
if('undefined' !== typeof command.xRot) {
// Skip if this is a pure translation
if(a !== 1 || b !== 0 || c !== 0 || d !== 1) {
// Special case for singular matrix
if(det === 0) {
// In the singular case, the arc is compressed to a line. The actual geometric image of the original
// curve under this transform possibly extends beyond the starting and/or ending points of the segment, but
// for simplicity we ignore this detail and just replace this command with a single line segment.
delete command.rX;
delete command.rY;
delete command.xRot;
delete command.lArcFlag;
delete command.sweepFlag;
command.type = SVGPathData.LINE_TO;
} else {
// Convert to radians
var xRot = command.xRot*Math.PI/180;

// Convert rotated ellipse to general conic form
// x0^2/rX^2 + y0^2/rY^2 - 1 = 0
// x0 = x*cos(xRot) + y*sin(xRot)
// y0 = -x*sin(xRot) + y*cos(xRot)
// --> A*x^2 + B*x*y + C*y^2 - 1 = 0, where
var sinRot = Math.sin(xRot), cosRot = Math.cos(xRot),
xCurve = 1/sq(command.rX), yCurve = 1/sq(command.rY);
var A = sq(cosRot)*xCurve + sq(sinRot)*yCurve,
B = 2*sinRot*cosRot*(xCurve - yCurve),
C = sq(sinRot)*xCurve + sq(cosRot)*yCurve;

// Apply matrix to A*x^2 + B*x*y + C*y^2 - 1 = 0
// x1 = a*x + c*y
// y1 = b*x + d*y
// (we can ignore e and f, since pure translations don't affect the shape of the ellipse)
// --> A1*x1^2 + B1*x1*y1 + C1*y1^2 - det^2 = 0, where
var A1 = A*d*d - B*b*d + C*b*b,
B1 = B*(a*d + b*c) - 2*(A*c*d + C*a*b),
C1 = A*c*c - B*a*c + C*a*a;

// Unapply newXRot to get back to axis-aligned ellipse equation
// x1 = x2*cos(newXRot) - y2*sin(newXRot)
// y1 = x2*sin(newXRot) + y2*cos(newXRot)
// A1*x1^2 + B1*x1*y1 + C1*y1^2 - det^2 =
// x2^2*(A1*cos(newXRot)^2 + B1*sin(newXRot)*cos(newXRot) + C1*sin(newXRot)^2)
// + x2*y2*(2*(C1 - A1)*sin(newXRot)*cos(newXRot) + B1*(cos(newXRot)^2 - sin(newXRot)^2))
// + y2^2*(A1*sin(newXRot)^2 - B1*sin(newXRot)*cos(newXRot) + C1*cos(newXRot)^2)
// (which must equal)
// x2^2/newRX^2 + y2^2/newRY^2 - 1
// (so we have)
// 2*(C1 - A1)*sin(newXRot)*cos(newXRot) + B1*(cos(newXRot)^2 - sin(newXRot)^2) = 0
// (A1 - C1)*sin(2*newXRot) = B1*cos(2*newXRot)
// 2*newXRot = atan2(B1, A1 - C1)
var newXRot = ((Math.atan2(B1, A1 - C1) + Math.PI) % Math.PI)/2;
// For any integer n, (atan2(B1, A1 - C1) + n*pi)/2 is a solution to the above; incrementing n just swaps the
// x and y radii computed below (since that's what rotating an ellipse by pi/2 does). Choosing the rotation
// between 0 and pi/2 eliminates the ambiguity and leads to more predictable output.

var newSinRot = Math.sin(newXRot), newCosRot = Math.cos(newXRot);
command.rX = Math.abs(det)/Math.sqrt(A1*sq(newCosRot) + B1*newSinRot*newCosRot + C1*sq(newSinRot));
command.rY = Math.abs(det)/Math.sqrt(A1*sq(newSinRot) - B1*newSinRot*newCosRot + C1*sq(newCosRot));
command.xRot = newXRot*180/Math.PI;
}
}
}
// sweepFlag needs to be inverted when mirroring shapes
// see http://www.itk.ilstu.edu/faculty/javila/SVG/SVG_drawing1/elliptical_curve.htm
// m 65,10 a 50,25 0 1 0 50,25
// M 65,60 A 50,25 0 1 1 115,35
if('undefined' !== typeof command.sweepFlag) {
command.sweepFlag = (command.sweepFlag + (0 <= d ? 0 : 1)) % 2;
command.sweepFlag = (command.sweepFlag + (0 <= det ? 0 : 1)) % 2;
}

prevX = ('undefined' !== typeof command.x ?
Expand Down
70 changes: 70 additions & 0 deletions tests/arc.mocha.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,73 @@ describe("Encoding eliptical arc commands", function() {
});

});

describe("Transforming elliptical arc commands", function() {

function assertDeepCloseTo(x, y, delta) {
if(typeof x === 'number' && typeof y === 'number') {
assert.closeTo(x, y, delta);
} else if(typeof x === 'object' && typeof y === 'object') {
var keys = Object.getOwnPropertyNames(x);
assert.sameMembers(keys, Object.getOwnPropertyNames(y));
for(var i = 0; i < keys.length; i++) {
assertDeepCloseTo(x[keys[i]], y[keys[i]], delta);
}
} else if(typeof x === 'array' && typeof y === 'array') {
assert.equal(x.length, y.length, 'arrays have different lengths');
for(var i = 0; i < x.length; i++) {
assertDeepCloseTo(x[i], y[i], delta);
}
} else {
assert.equal(x, y);
}
}

it("should rotate an axis-aligned arc", function() {
assertDeepCloseTo(
new SVGPathData('M 0,0 A 100,50 0 0 1 100,50z').rotate(Math.PI/6).commands,
new SVGPathData('M 0,0 A 100,50 30 0 1 61.6,93.3z').commands,
0.1
);
});

it("should rotate an arbitrary arc", function() {
assertDeepCloseTo(
new SVGPathData('M 0,0 A 100,50 -15 0 1 100,0z').rotate(Math.PI/4).commands,
new SVGPathData('M 0,0 A 100,50 30 0 1 70.7,70.7z').commands,
0.1
);
});

it("should skew", function() {
assertDeepCloseTo(
new SVGPathData('M 0,0 A 50,100 0 0 1 50,100z').skewX(Math.tan(-1)).commands,
new SVGPathData('M 0,0 A 34.2,146.0 48.6 0 1 -50,100 Z').commands,
0.1
);
});

it("should tolerate singular matrices", function() {
assertDeepCloseTo(
new SVGPathData('M 0,0 A 80,80 0 0 1 50,100z').matrix(0.8,2,0.5,1.25,0,0).commands,
new SVGPathData('M 0,0 L 90,225 Z').commands,
0.1
);
});

it("should match what Inkscape does on this random case", function() {
assertDeepCloseTo(
new SVGPathData('M 170.19275,911.55263 A 61.42857,154.28572 21.033507 0 1 57.481868,1033.5109 61.42857,154.28572 21.033507 0 1 55.521508,867.4575 61.42857,154.28572 21.033507 0 1 168.2324,745.4993 A 61.42857,154.28572 21.033507 0 1 170.19275,911.55263 z').matrix(-0.10825745,-0.37157241,0.77029181,0.3345653,-560.10375,633.84215).commands,
new SVGPathData('M 123.63314,875.5771 A 135.65735,17.465974 30.334289 0 1 229.77839,958.26036 135.65735,17.465974 30.334289 0 1 102.08104,903.43307 135.65735,17.465974 30.334289 0 1 -4.0641555,820.74983 135.65735,17.465974 30.334289 0 1 123.63314,875.5771 z').commands,
0.0001
);
});

it("should reflect the sweep flag any time the determinant is negative", function() {
assertDeepCloseTo(
new SVGPathData('M 0,0 A 50,100 -30 1 1 80,80 Z').matrix(-1,0,0,1,0,0).commands,
new SVGPathData('M 0,0 A 50,100 30 1 0 -80,80 Z').commands,
0.1
);
});
});

0 comments on commit b4cbed7

Please sign in to comment.