diff --git a/lib/affine_transform.js b/lib/affine_transform.js new file mode 100644 index 0000000..a3db4d4 --- /dev/null +++ b/lib/affine_transform.js @@ -0,0 +1,173 @@ +'use strict'; + +// compose 2 matrices representing affine transforms +// if [Li,Ti] with +// Li 2x2 matrix (the linear part) encoded by [m[0] m[2]] +// [m[1] m[3]] +// and Ti 1x2 matrix (the translation part) encoded by [m[4]] +// [m[5]] +// then m1 x m2 = [L1*L2, L1*A2+A1] +// +function compose(m1, m2) { + return [ + m1[0] * m2[0] + m1[2] * m2[1], + m1[1] * m2[0] + m1[3] * m2[1], + m1[0] * m2[2] + m1[2] * m2[3], + m1[1] * m2[2] + m1[3] * m2[3], + m1[0] * m2[4] + m1[2] * m2[5] + m1[4], + m1[1] * m2[4] + m1[3] * m2[5] + m1[5] + ]; +} + + +// Class constructor +// the parameter could be : +// - an array : [a, c, b, d, tx, ty] +// if the array is not complete it is completed by the missing elements of the identity +// for example +// - AffineTransform([]) or AffineTransform() creates the identity +// - AffineTransform([a b c d]) creates a linear transform (translation part = [0 0]) +// - a string : "a c b d tx ty" then it is parsed to an array (and completed if needed) +// - another AffineTransform : it is copied +// - empty or something else : the identity transform is created +// +function AffineTransform(m) { + if (!(this instanceof AffineTransform)) { return new AffineTransform(m); } + // make the paramyter array if it is not + if (!m) { + // if m is empty, it becomes empty array + m = []; + } else { + switch (m.constructor) { + case String : + m = m.trim().split(/\s+/).map(parseFloat); + break; + case AffineTransform : + m = m.toArray(); + break; + case Array : + break; + default: + m = []; + } + } + // complete the matrix by identity + this.matrix = m.slice().concat([ 1, 0, 0, 1, 0, 0 ].slice(m.length)); +} + +// return true if the transform is identity +// +AffineTransform.prototype.isIdentity = function (epsilon) { + if (epsilon) { + return ((this.matrix[0] - 1) * (this.matrix[0] - 1) + + this.matrix[1] * this.matrix[1] + + this.matrix[2] * this.matrix[2] + + (this.matrix[3] - 1) * (this.matrix[3] - 1) + + this.matrix[4] * this.matrix[4] + + this.matrix[5] * this.matrix[5]) < epsilon; + } + + return (this.matrix[0] === 1 && this.matrix[1] === 0 && this.matrix[2] === 0 && this.matrix[3] === 1) && + (this.matrix[4] === 0 && this.matrix[5] === 0); +}; + +// set the transform to identity +// +AffineTransform.prototype.reset = function () { + this.matrix = [ 1, 0, 0, 1, 0, 0 ]; + + return this; +}; + + +// compose (multiply on the left) by at +// +AffineTransform.prototype.compose = function (at) { + if (!at || at.constructor !== AffineTransform) { + at = new AffineTransform(at); + } + + if (at.isIdentity()) { + return this; + } + + this.matrix = compose(at.matrix, this.matrix); + + return this; +}; + +// compose (multiply on the left) by a translation +// +AffineTransform.prototype.translate = function (tx, ty) { + this.matrix[4] += tx; + this.matrix[5] += ty; + return this; +}; + +// compose (multiply on the left) by a scale (diagonal matrix) +// +AffineTransform.prototype.scale = function (sx, sy) { + if (sx !== 1 || sy !== 1) { + this.matrix[0] *= sx; this.matrix[2] *= sx; this.matrix[4] *= sx; + this.matrix[1] *= sy; this.matrix[3] *= sy; this.matrix[5] *= sy; + } + return this; +}; + +// compose (multiply on the left) by a rotation (diagonal matrix) +// +AffineTransform.prototype.rotate = function (angle, rx, ry) { + var rad, cos, sin; + + if (angle !== 0) { + rad = angle * Math.PI / 180; + cos = Math.cos(rad); + sin = Math.sin(rad); + + this + .translate(-rx, -ry) + .compose([ cos, sin, -sin, cos, 0, 0 ]) + .translate(rx, ry); + } + return this; +}; + +// compose (multiply on the left) by a skewW matrix +// +AffineTransform.prototype.skewX = function (angle) { + if (angle !== 0) { + this.compose([ 1, 0, Math.tan(angle * Math.PI / 180), 1, 0, 0 ]); + } + + return this; +}; + + +// compose (multiply on the left) by a skewY matrix +// +AffineTransform.prototype.skewY = function (angle) { + if (angle !== 0) { + this.compose([ 1, Math.tan(angle * Math.PI / 180), 0, 1, 0, 0 ]); + } + + return this; +}; + + +// Get the array representing the transform. +// +AffineTransform.prototype.toArray = function () { + return this.matrix; +}; + + +// Apply the transform to (x,y) point. +// If `isRelative` set, `translate` component of AffineTransform will be skipped +// +AffineTransform.prototype.calc = function (x, y, isRelative) { + return [ this.matrix[0] * x + this.matrix[2] * y + (isRelative ? 0 : this.matrix[4]), + this.matrix[1] * x + this.matrix[3] * y + (isRelative ? 0 : this.matrix[5]) ]; +}; + + +module.exports = AffineTransform; diff --git a/lib/matrix.js b/lib/matrix.js deleted file mode 100644 index 641d8fc..0000000 --- a/lib/matrix.js +++ /dev/null @@ -1,144 +0,0 @@ -'use strict'; - -// combine 2 matrixes -// m1, m2 - [a, b, c, d, e, g] -// -function combine(m1, m2) { - return [ - m1[0] * m2[0] + m1[2] * m2[1], - m1[1] * m2[0] + m1[3] * m2[1], - m1[0] * m2[2] + m1[2] * m2[3], - m1[1] * m2[2] + m1[3] * m2[3], - m1[0] * m2[4] + m1[2] * m2[5] + m1[4], - m1[1] * m2[4] + m1[3] * m2[5] + m1[5] - ]; -} - - -function Matrix() { - if (!(this instanceof Matrix)) { return new Matrix(); } - this.queue = []; // list of matrixes to apply - this.cache = null; // combined matrix cache -} - - -Matrix.prototype.matrix = function (m) { - if (m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1 && m[4] === 0 && m[5] === 0) { - return this; - } - this.cache = null; - this.queue.push(m); - return this; -}; - - -Matrix.prototype.translate = function (tx, ty) { - if (tx !== 0 || ty !== 0) { - this.cache = null; - this.queue.push([ 1, 0, 0, 1, tx, ty ]); - } - return this; -}; - - -Matrix.prototype.scale = function (sx, sy) { - if (sx !== 1 || sy !== 1) { - this.cache = null; - this.queue.push([ sx, 0, 0, sy, 0, 0 ]); - } - return this; -}; - - -Matrix.prototype.rotate = function (angle, rx, ry) { - var rad, cos, sin; - - if (angle !== 0) { - this.translate(rx, ry); - - rad = angle * Math.PI / 180; - cos = Math.cos(rad); - sin = Math.sin(rad); - - this.queue.push([ cos, sin, -sin, cos, 0, 0 ]); - this.cache = null; - - this.translate(-rx, -ry); - } - return this; -}; - - -Matrix.prototype.skewX = function (angle) { - if (angle !== 0) { - this.cache = null; - this.queue.push([ 1, 0, Math.tan(angle * Math.PI / 180), 1, 0, 0 ]); - } - return this; -}; - - -Matrix.prototype.skewY = function (angle) { - if (angle !== 0) { - this.cache = null; - this.queue.push([ 1, Math.tan(angle * Math.PI / 180), 0, 1, 0, 0 ]); - } - return this; -}; - - -// Flatten queue -// -Matrix.prototype.toArray = function () { - if (this.cache) { - return this.cache; - } - - if (!this.queue.length) { - this.cache = [ 1, 0, 0, 1, 0, 0 ]; - return this.cache; - } - - this.cache = this.queue[0]; - - if (this.queue.length === 1) { - return this.cache; - } - - for (var i = 1; i < this.queue.length; i++) { - this.cache = combine(this.cache, this.queue[i]); - } - - return this.cache; -}; - - -// Apply list of matrixes to (x,y) point. -// If `isRelative` set, `translate` component of matrix will be skipped -// -Matrix.prototype.calc = function (x, y, isRelative) { - var m, i, len; - - // Don't change point on empty transforms queue - if (!this.queue.length) { return [ x, y ]; } - - // Calculate final matrix, if not exists - // - // NB. if you deside to apply transforms to point one-by-one, - // they should be taken in reverse order - - if (!this.cache) { - this.cache = this.toArray(); - } - - m = this.cache; - - // Apply matrix to point - return [ - x * m[0] + y * m[2] + (isRelative ? 0 : m[4]), - x * m[1] + y * m[3] + (isRelative ? 0 : m[5]) - ]; -}; - - -module.exports = Matrix; diff --git a/lib/svgpath.js b/lib/svgpath.js index bc6d50e..b24cab2 100644 --- a/lib/svgpath.js +++ b/lib/svgpath.js @@ -6,17 +6,17 @@ // .translate(-150, -100) // .scale(0.5) // .translate(-150, -100) -// .toFixed(1) +// .round(1) // .toString() // 'use strict'; -var pathParse = require('./path_parse'); -var transformParse = require('./transform_parse'); -var matrix = require('./matrix'); -var a2c = require('./a2c'); +var pathParse = require('./path_parse'); +var transformParse = require('./transform_parse'); +var affineTransform = require('./affine_transform'); +var a2c = require('./a2c'); var ellipse = require('./ellipse'); @@ -29,23 +29,20 @@ function SvgPath(path) { // Array of path segments. // Each segment is array [command, param1, param2, ...] - this.segments = pstate.segments; + this.segments = pstate.segments; // Error message on parse error. - this.err = pstate.err; + this.err = pstate.err; - // Transforms stack for lazy evaluation - this.__stack = []; + // The current transform (identity at the begining) + this.affineTransform = affineTransform(); } -SvgPath.prototype.__matrix = function (m) { +SvgPath.prototype.__transform = function (m) { var self = this, ma, sx, sy, angle, arc2line, i; - // Quick leave for empty matrix - if (!m.queue.length) { return; } - this.iterate(function (s, index, x, y) { var p, result, name, isRelative; @@ -128,26 +125,13 @@ SvgPath.prototype.__matrix = function (m) { // Apply stacked commands // -SvgPath.prototype.__evaluateStack = function () { - var m, i; - - if (!this.__stack.length) { return; } - - if (this.__stack.length === 1) { - this.__matrix(this.__stack[0]); - this.__stack = []; - return; - } +SvgPath.prototype.applyTransform = function () { + if (this.affineTransform.isIdentity()) { return this; } - m = matrix(); - i = this.__stack.length; + this.__transform(this.affineTransform); + this.affineTransform.reset(); - while (--i >= 0) { - m.matrix(this.__stack[i].toArray()); - } - - this.__matrix(m); - this.__stack = []; + return this; }; @@ -156,7 +140,7 @@ SvgPath.prototype.__evaluateStack = function () { SvgPath.prototype.toString = function () { var elements = [], skipCmd, cmd; - this.__evaluateStack(); + this.applyTransform(); for (var i = 0; i < this.segments.length; i++) { // remove repeating commands names @@ -180,7 +164,7 @@ SvgPath.prototype.toString = function () { // Translate path to (x [, y]) // SvgPath.prototype.translate = function (x, y) { - this.__stack.push(matrix().translate(x, y || 0)); + this.affineTransform.translate(x, y || 0); return this; }; @@ -189,7 +173,7 @@ SvgPath.prototype.translate = function (x, y) { // sy = sx if not defined // SvgPath.prototype.scale = function (sx, sy) { - this.__stack.push(matrix().scale(sx, (!sy && (sy !== 0)) ? sx : sy)); + this.affineTransform.scale(sx, isNaN(sy) ? sx : sy); return this; }; @@ -198,7 +182,7 @@ SvgPath.prototype.scale = function (sx, sy) { // sy = sx if not defined // SvgPath.prototype.rotate = function (angle, rx, ry) { - this.__stack.push(matrix().rotate(angle, rx || 0, ry || 0)); + this.affineTransform.rotate(angle, rx || 0, ry || 0); return this; }; @@ -206,7 +190,7 @@ SvgPath.prototype.rotate = function (angle, rx, ry) { // Apply matrix transform (array of 6 elements) // SvgPath.prototype.matrix = function (m) { - this.__stack.push(matrix().matrix(m)); + this.affineTransform.compose(m); return this; }; @@ -217,7 +201,7 @@ SvgPath.prototype.transform = function (transformString) { if (!transformString.trim()) { return this; } - this.__stack.push(transformParse(transformString)); + this.affineTransform.compose(transformParse(transformString)); return this; }; @@ -230,7 +214,7 @@ SvgPath.prototype.round = function (d) { d = d || 0; - this.__evaluateStack(); + this.applyTransform(); this.segments.forEach(function (s) { var isRelative = (s[0].toLowerCase() === s[0]), t; @@ -330,7 +314,7 @@ SvgPath.prototype.iterate = function (iterator, keepLazyStack) { var i, j, isRelative, newSegments; if (!keepLazyStack) { - this.__evaluateStack(); + this.applyTransform(); } segments.forEach(function (s, index) { diff --git a/lib/transform_parse.js b/lib/transform_parse.js index 7ba3c95..f317879 100644 --- a/lib/transform_parse.js +++ b/lib/transform_parse.js @@ -1,9 +1,9 @@ 'use strict'; -var Matrix = require('./matrix'); +var affineTransform = require('./affine_transform'); -var operations = { +var numeric_params = { matrix: true, scale: true, rotate: true, @@ -12,76 +12,76 @@ var operations = { skewY: true }; -var CMD_SPLIT_RE = /\s*(matrix|translate|scale|rotate|skewX|skewY)\s*\(\s*(.+?)\s*\)[\s,]*/; -var PARAMS_SPLIT_RE = /[\s,]+/; +var CMD_SPLIT_RE = /\s*((?:matrix|translate|scale|rotate|skewX|skewY)\s*\(\s*(?:.+?)\s*\))[\s,]*/; +var PARAMS_SPLIT_RE = /[\s(),]+/; module.exports = function transformParse(transformString) { - var matrix = new Matrix(); - var cmd, params; - - // Split value into ['', 'translate', '10 50', '', 'scale', '2', '', 'rotate', '-45', ''] - transformString.split(CMD_SPLIT_RE).forEach(function (item) { - - // Skip empty elements - if (!item.length) { return; } - - // remember operation - if (typeof operations[item] !== 'undefined') { - cmd = item; - return; + var theTransform = affineTransform(); + var transforms, cmd, params; + + // Split value into ['', 'translate(10 50)', '', 'scale(2)', '', 'rotate(-45)', ''] + // then eliminate empty strings with .filter(Boolean) + transforms = transformString.split(CMD_SPLIT_RE).filter(Boolean); + for (var i = transforms.length - 1; i >= 0; i--) { + // params will be something like ["scale","1","2"] + params = transforms[i].split(PARAMS_SPLIT_RE).filter(Boolean); + + // Skip bad commands (if any) + if (params.length < 2) { continue; } + + // separate the command from the parameters + cmd = params.shift(); + // if all parameters should be numeric, parse them + if (numeric_params[cmd]) { + params = params.map(parseFloat); } - // extract params & att operation to matrix - params = item.split(PARAMS_SPLIT_RE).map(function (i) { - return +i || 0; - }); - // If params count is not correct - ignore command switch (cmd) { case 'matrix': - if (params.length === 6) { - matrix.matrix(params); + if (params.length === 6 || params.length === 4) { + theTransform.compose(params); } - return; + break; case 'scale': if (params.length === 1) { - matrix.scale(params[0], params[0]); + theTransform.scale(params[0], params[0]); } else if (params.length === 2) { - matrix.scale(params[0], params[1]); + theTransform.scale(params[0], params[1]); } - return; + break; case 'rotate': if (params.length === 1) { - matrix.rotate(params[0], 0, 0); + theTransform.rotate(params[0], 0, 0); } else if (params.length === 3) { - matrix.rotate(params[0], params[1], params[2]); + theTransform.rotate(params[0], params[1], params[2]); } - return; + break; case 'translate': if (params.length === 1) { - matrix.translate(params[0], 0); + theTransform.translate(params[0], 0); } else if (params.length === 2) { - matrix.translate(params[0], params[1]); + theTransform.translate(params[0], params[1]); } - return; + break; case 'skewX': if (params.length === 1) { - matrix.skewX(params[0]); + theTransform.skewX(params[0]); } - return; + break; case 'skewY': if (params.length === 1) { - matrix.skewY(params[0]); + theTransform.skewY(params[0]); } - return; - } - }); + break; + } // end switch + } // end for - return matrix; + return theTransform; }; diff --git a/test/affine_transform.js b/test/affine_transform.js new file mode 100644 index 0000000..fc283aa --- /dev/null +++ b/test/affine_transform.js @@ -0,0 +1,60 @@ +'use strict'; + + +var assert = require('assert'); +var affineTransform = require('../lib/affine_transform'); + +var at; + +describe('AffineTransform', function () { + + it('constructor', function () { + at = affineTransform(); + assert.deepEqual(at.toArray(), [ 1, 0, 0, 1, 0, 0 ]); + + at = affineTransform([ 1, 2, 3, 4 ]); + assert.deepEqual(at.toArray(), [ 1, 2, 3, 4, 0, 0 ]); + + at = affineTransform('1 2 3 4'); + assert.deepEqual(at.toArray(), [ 1, 2, 3, 4, 0, 0 ]); + + at = affineTransform([ 1, 2, 3, 4, 5, 7 ]); + assert.deepEqual(at.toArray(), [ 1, 2, 3, 4, 5, 7 ]); + + at = affineTransform('1 2 3 4 5 7'); + assert.deepEqual(at.toArray(), [ 1, 2, 3, 4, 5, 7 ]); + }); + + it('trivial transform', function () { + at = affineTransform(); + + at = affineTransform([ 1, 2, 3, 4 ]); + assert(!at.isIdentity()); + + at = affineTransform([ 1, 2, 3, 4 ]).reset(); + assert(at.isIdentity()); + }); + + it('compose', function () { + at = affineTransform([ 1, 2, 3, 4, 1, 2 ]).compose([ 1, -2, 3, -4, -1, -2 ]); + assert.deepEqual(at.toArray(), [ 7, -10, 15, -22, 6, -12 ]); + }); + + it('standard transforms', function () { + at = affineTransform([ 1, 2, 3, 4, 1, 2 ]).translate(1, 2); + assert.deepEqual(at.toArray(), [ 1, 2, 3, 4, 2, 4 ]); + + at = affineTransform([ 1, 2, 3, 4, 1, 2 ]).scale(1.5, 2); + assert.deepEqual(at.toArray(), [ 1.5, 4, 4.5, 8, 1.5, 4 ]); + + at = affineTransform([ 100, 200, 300, 400, 100, 200 ]).rotate(42, 5, -4); + assert.deepEqual(at.toArray().map(Math.round), [ -60, 216, -45, 498, -61, 211 ]); + + at = affineTransform([ 100, 200, 300, 400, 100, 200 ]).skewX(42); + assert.deepEqual(at.toArray().map(Math.round), [ 280, 200, 660, 400, 280, 200 ]); + + at = affineTransform([ 100, 200, 300, 400, 100, 200 ]).skewY(42); + assert.deepEqual(at.toArray().map(Math.round), [ 100, 290, 300, 670, 100, 290 ]); + }); + +}); diff --git a/test/matrix.js b/test/matrix.js deleted file mode 100644 index b35c4e4..0000000 --- a/test/matrix.js +++ /dev/null @@ -1,67 +0,0 @@ -'use strict'; - - -var assert = require('assert'); -var matrix = require('../lib/matrix'); - -var m; - -describe('Matrix', function () { - - it('ignore empty actions', function () { - m = matrix(); - - m.matrix([ 1, 0, 0, 1, 0, 0 ]); - assert.equal(m.queue.length, 0); - - m.translate(0, 0); - assert.equal(m.queue.length, 0); - - m.scale(1, 1); - assert.equal(m.queue.length, 0); - - m.rotate(0); - assert.equal(m.queue.length, 0); - - m.skewX(0); - assert.equal(m.queue.length, 0); - - m.skewY(0); - assert.equal(m.queue.length, 0); - }); - - it('do nothing on empty queue', function () { - assert.deepEqual(matrix().calc(10, 11, false), [ 10, 11 ]); - assert.deepEqual(matrix().toArray(), [ 1, 0, 0, 1, 0, 0 ]); - }); - - it('compose', function () { - m = matrix() - .translate(10, 10) - .translate(-10, -10) - .rotate(180, 10, 10) - .rotate(180, 10, 10) - .toArray(); - - // Need to round errors prior to compare - assert.equal(+m[0].toFixed(2), 1); - assert.equal(+m[1].toFixed(2), 0); - assert.equal(+m[2].toFixed(2), 0); - assert.equal(+m[3].toFixed(2), 1); - assert.equal(+m[4].toFixed(2), 0); - assert.equal(+m[5].toFixed(2), 0); - }); - - it('cache', function () { - m = matrix() - .translate(10, 20) - .scale(2, 3); - - assert.strictEqual(m.cache, null); - assert.deepEqual(m.toArray(), [ 2, 0, 0, 3, 10, 20 ]); - assert.deepEqual(m.cache, [ 2, 0, 0, 3, 10, 20 ]); - m.cache = [ 1, 2, 3, 4, 5, 6 ]; - assert.deepEqual(m.toArray(), [ 1, 2, 3, 4, 5, 6 ]); - }); - -});