diff --git a/README.md b/README.md index 6cfe004..ab0aada 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,25 @@ const intersection = intersect(path0, path1); Results are approximate, as we use [bezier clipping](https://math.stackexchange.com/questions/118937) to find intersections. +## Path Caching + +Where performance matters, you can pre-parse paths and cache them: + +```javascript +import intersect, { parsePath } from 'path-intersection'; + +// parse paths once +const path1 = parsePath('M0,0L100,100'); +const path2 = parsePath('M0,100L100,0'); + +// they won't be re-parsed during intersection checking +const result1 = intersect(path1, path2); +const result2 = intersect(path2, path2); +``` + +For repeated calculations, this optimization can result in substantial performance improvements. + + ## Building the Project ``` diff --git a/intersect.d.ts b/intersect.d.ts index 7b764db..ec269ec 100644 --- a/intersect.d.ts +++ b/intersect.d.ts @@ -9,11 +9,16 @@ * on each path (segment1, segment2) and the relative location of the * intersection on these segments (t1, t2). * - * The path may be an SVG path string or a list of path components + * The path may be an SVG path string or an array of path components * such as `[ [ 'M', 0, 10 ], [ 'L', 20, 0 ] ]`. * + * For performance optimization, pre-parsed paths can be passed directly, + * {@link parsePath | the parsePath utility} can be used to pre-parse any path. + * * @example * + * import findPathIntersections from 'path-intersection'; + * * var intersections = findPathIntersections( * 'M0,0L100,100', * [ [ 'M', 0, 100 ], [ 'L', 100, 0 ] ] @@ -23,11 +28,11 @@ * // { x: 50, y: 50, segment1: 1, segment2: 1, t1: 0.5, t2: 0.5 } * // ]; * - * @param {String|Array} path1 - * @param {String|Array} path2 - * @param {Boolean} [justCount=false] + * @param {Path} path1 + * @param {Path} path2 + * @param {boolean} [justCount=false] * - * @return {Array|Number} + * @return {Intersection[]|number} */ declare function findPathIntersections(path1: Path, path2: Path, justCount: true): number; declare function findPathIntersections(path1: Path, path2: Path, justCount: false): Intersection[]; @@ -37,18 +42,57 @@ declare function findPathIntersections(path1: Path, path2: Path, justCount?: boo export default findPathIntersections; /** - * A string in the form of 'M150,150m0,-18a18,18,0,1,1,0,36a18,18,0,1,1,0,-36z' - * or something like: + * Parses a path to an optimized format. The result can be cached + * and reused to maximize performance in subsequent intersection calculations. + * + * This is the recommended way to pre-parse paths for repeated use. + * Paths parsed this way will not be re-parsed when passed to + * {@link findPathIntersections | the intersect function}. + * + * @example + * + * import intersect, { parsePath } from 'path-intersection'; + * + * // parse once + * const path1 = parsePath('M0,0L100,100'); + * const path2 = parsePath('M0,100L100,0'); + * + * // cache and reuse + * const result1 = intersect(path1, parsedPath2); + * const result2 = intersect(path2, parsedPath2); + * + * @param {Path} path - the path to parse + * + * @return {PathComponent[]} pre-parsed and optimized path + */ +export function parsePath(path: Path): PathComponent[]; + +/** + * A SVG path string, or it's array encoded version. + * + * @example + * + * "M150,150m0,-18a18,18,0,1,1,0,36a18,18,0,1,1,0,-36z" + * + * @example + * * [ - * ['M', 1, 2], - * ['m', 0, -2], - * ['a', 1, 1, 0, 1, 1, 0, 2 * 1], - * ['a', 1, 1, 0, 1, 1, 0, -2 * 1], - * ['z'] + * ['M', 1, 2], + * ['m', 0, -2], + * ['a', 1, 1, 0, 1, 1, 0, 2 * 1], + * ['a', 1, 1, 0, 1, 1, 0, -2 * 1], + * ['z'] * ] */ declare type Path = string | PathComponent[]; -declare type PathComponent = any[]; +/** + * A SVG path component, stored as an array with the operation, and parameters. + * + * @example + * + * ['M', 1, 2] + */ +declare type PathComponent = [ string, ...number[] ]; declare interface Intersection { /** diff --git a/intersect.js b/intersect.js index 5925720..3091685 100644 --- a/intersect.js +++ b/intersect.js @@ -6,6 +6,12 @@ /* eslint no-fallthrough: "off" */ +/** + * @typedef { import('./intersect.js').Path } Path + * @typedef { import('./intersect.js').PathComponent } PathComponent + */ + + var p2s = /,?([a-z]),?/gi, toFloat = parseFloat, math = Math, @@ -23,23 +29,6 @@ function hasProperty(obj, property) { return Object.prototype.hasOwnProperty.call(obj, property); } -function clone(obj) { - - if (typeof obj == 'function' || Object(obj) !== obj) { - return obj; - } - - var res = new obj.constructor; - - for (var key in obj) { - if (hasProperty(obj, key)) { - res[key] = clone(obj[key]); - } - } - - return res; -} - function repush(array, item) { for (var i = 0, ii = array.length; i < ii; i++) if (array[i] === item) { return array.push(array.splice(i, 1)[0]); @@ -69,77 +58,84 @@ function cacher(f) { return newf; } +/** + * Parse SVG path string and return an array of path components. + * + * @param {string} pathString + * + * @return {PathComponent[]} + */ function parsePathString(pathString) { if (!pathString) { return null; } - var pth = paths(pathString); - - if (pth.arr) { - return clone(pth.arr); - } - var paramCounts = { a: 7, c: 6, h: 1, l: 2, m: 2, q: 4, s: 4, t: 2, v: 1, z: 0 }, - data = []; - - if (isArray(pathString) && isArray(pathString[0])) { // rough assumption - data = clone(pathString); - } - - if (!data.length) { + pathComponents = []; - String(pathString).replace(pathCommand, function(a, b, c) { - var params = [], - name = b.toLowerCase(); + String(pathString).replace(pathCommand, function(a, b, c) { + var params = [], + name = b.toLowerCase(); - c.replace(pathValues, function(a, b) { - b && params.push(+b); - }); + c.replace(pathValues, function(a, b) { + b && params.push(+b); + }); - if (name == 'm' && params.length > 2) { - data.push([ b, ...params.splice(0, 2) ]); - name = 'l'; - b = b == 'm' ? 'l' : 'L'; - } + if (name == 'm' && params.length > 2) { + pathComponents.push([ b, ...params.splice(0, 2) ]); + name = 'l'; + b = b == 'm' ? 'l' : 'L'; + } - while (params.length >= paramCounts[name]) { - data.push([ b, ...params.splice(0, paramCounts[name]) ]); - if (!paramCounts[name]) { - break; - } + while (params.length >= paramCounts[name]) { + pathComponents.push([ b, ...params.splice(0, paramCounts[name]) ]); + if (!paramCounts[name]) { + break; } - }); - } + } + }); - data.toString = paths.toString; - pth.arr = clone(data); + pathComponents.toString = pathToString; - return data; + return pathComponents; } -function paths(ps) { - var p = paths.ps = paths.ps || {}; - - if (p[ps]) { - p[ps].sleep = 100; - } else { - p[ps] = { - sleep: 100 - }; +/** + * Checks if a path is already in absolute format. + * An absolute path has all uppercase commands. + * + * @param {PathComponent[]} pathComponents + * @return {boolean} + */ +function isPathAbsolute(pathComponents) { + for (var i = 0, ii = pathComponents.length; i < ii; i++) { + var command = pathComponents[i][0]; + if (typeof command === 'string' && command !== command.toUpperCase()) { + return false; + } } - setTimeout(function() { - for (var key in p) { - if (hasProperty(p, key) && key != ps) { - p[key].sleep--; - !p[key].sleep && delete p[key]; - } + return true; +} + +/** + * Checks if a path is already in curve format. + * A curve path only contains 'M' and 'C' commands. + * + * @param {PathComponent[]} pathComponents + * + * @return {boolean} + */ +function isPathCurve(pathComponents) { + for (var i = 0, ii = pathComponents.length; i < ii; i++) { + var command = pathComponents[i][0]; + if (command !== 'M' && command !== 'C') { + return false; } - }); + } - return p[ps]; + return true; } function rectBBox(x, y, width, height) { @@ -165,10 +161,27 @@ function pathToString() { return this.join(',').replace(p2s, '$1'); } -function pathClone(pathArray) { - var res = clone(pathArray); - res.toString = pathToString; - return res; +/** + * @param {PathComponent[]} pathComponents + * + * @return {PathComponent[]} + */ +function pathClone(pathComponents) { + + var pathComponentsClone = new Array(pathComponents.length); + + for (var i = 0, ii = pathComponents.length; i < ii; i++) { + var component = pathComponents[i]; + var componentClone = pathComponentsClone[i] = new Array(component.length); + + for (var j = 0, jj = component.length; j < jj; j++) { + componentClone[j] = component[j]; + } + } + + pathComponentsClone.toString = pathToString; + + return pathComponentsClone; } function findDotsAtSegment(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, t) { @@ -385,11 +398,16 @@ function findBezierIntersections(bez1, bez2, justCount) { * on each path (segment1, segment2) and the relative location of the * intersection on these segments (t1, t2). * - * The path may be an SVG path string or a list of path components + * The path may be an SVG path string or an array of path components * such as `[ [ 'M', 0, 10 ], [ 'L', 20, 0 ] ]`. * + * For performance optimization, pre-parsed paths can be passed directly, + * {@link parsePath | the parsePath utility} can be used to pre-parse any path. + * * @example * + * import findPathIntersections from 'path-intersection'; + * * var intersections = findPathIntersections( * 'M0,0L100,100', * [ [ 'M', 0, 100 ], [ 'L', 100, 0 ] ] @@ -399,11 +417,11 @@ function findBezierIntersections(bez1, bez2, justCount) { * // { x: 50, y: 50, segment1: 1, segment2: 1, t1: 0.5, t2: 0.5 } * // ] * - * @param {String|Array} path1 - * @param {String|Array} path2 - * @param {Boolean} [justCount=false] + * @param {Path} path1 + * @param {Path} path2 + * @param {boolean} [justCount=false] * - * @return {Array|Number} + * @return {Intersection[]|number} */ export default function findPathIntersections(path1, path2, justCount) { path1 = pathToCurve(path1); @@ -471,23 +489,30 @@ export default function findPathIntersections(path1, path2, justCount) { return res; } +/** + * Test if path is a set of path components of the form + * `[ ['M', 0, 0 ], ['L', 100, 100], ...]`. + * + * @param {Path} path + * + * @return {boolean} + */ +function isPathComponents(path) { + return isArray(path) && isArray(path[0]); +} -function pathToAbsolute(pathArray) { - var pth = paths(pathArray); - - if (pth.abs) { - return pathClone(pth.abs); - } - - if (!isArray(pathArray) || !isArray(pathArray && pathArray[0])) { // rough assumption - pathArray = parsePathString(pathArray); - } +/** + * @param {PathComponent[]} pathComponents + * + * @return {PathComponent[]} + */ +function pathToAbsolute(pathComponents) { - if (!pathArray || !pathArray.length) { - return [ [ 'M', 0, 0 ] ]; + if (isPathAbsolute(pathComponents)) { + return pathComponents; } - var res = [], + var res = new Array(pathComponents.length), x = 0, y = 0, mx = 0, @@ -495,19 +520,19 @@ function pathToAbsolute(pathArray) { start = 0, pa0; - if (pathArray[0][0] == 'M') { - x = +pathArray[0][1]; - y = +pathArray[0][2]; + if (pathComponents[0][0] == 'M') { + x = +pathComponents[0][1]; + y = +pathComponents[0][2]; mx = x; my = y; start++; res[0] = [ 'M', x, y ]; } - for (var r, pa, i = start, ii = pathArray.length; i < ii; i++) { - res.push(r = []); - pa = pathArray[i]; + for (var r, pa, i = start, ii = pathComponents.length; i < ii; i++) { + pa = pathComponents[i]; pa0 = pa[0]; + res[i] = (r = new Array(pa.length)); if (pa0 != pa0.toUpperCase()) { r[0] = pa0.toUpperCase(); @@ -564,7 +589,6 @@ function pathToAbsolute(pathArray) { } res.toString = pathToString; - pth.abs = pathClone(res); return res; } @@ -785,16 +809,24 @@ function curveBBox(x0, y0, x1, y1, x2, y2, x3, y3) { }; } +/** + * Convert path to a curve. + * + * @param {Path} path + * + * @return {PathComponent[]} + */ function pathToCurve(path) { - var pth = paths(path); + if (!isPathComponents(path)) { + path = parsePathString(path); + } - // return cached curve, if existing - if (pth.curve) { - return pathClone(pth.curve); + if (isPathCurve(path)) { + return path; } - var curvedPath = pathToAbsolute(path), + var curvedPath = pathClone(pathToAbsolute(path)), attrs = { x: 0, y: 0, bx: 0, by: 0, X: 0, Y: 0, qx: null, qy: null }, processPath = function(path, d, pathCommand) { var nx, ny; @@ -918,8 +950,33 @@ function pathToCurve(path) { attrs.by = toFloat(seg[seglen - 3]) || attrs.y; } - // cache curve - pth.curve = pathClone(curvedPath); - return curvedPath; +} + +/** + * Parses a path to an optimized format. The result can be cached + * and reused to maximize performance in subsequent intersection calculations. + * + * This is the recommended way to pre-parse paths for repeated use. + * Paths parsed this way will not be re-parsed when passed to + * {@link findPathIntersections | the intersect function}. + * + * @example + * + * import intersect, { parsePath } from 'path-intersection'; + * + * // parse once + * const path1 = parsePath('M0,0L100,100'); + * const path2 = parsePath('M0,100L100,0'); + * + * // cache and reuse + * const result1 = intersect(path1, parsedPath2); + * const result2 = intersect(path2, parsedPath2); + * + * @param {Path} path - the path to parse + * + * @return {PathComponent[]} pre-parsed and optimized path + */ +export function parsePath(path) { + return pathToCurve(path); } \ No newline at end of file diff --git a/test/intersect.spec.js b/test/intersect.spec.js index a4bffbe..5bb001e 100644 --- a/test/intersect.spec.js +++ b/test/intersect.spec.js @@ -1,4 +1,4 @@ -import intersect from 'path-intersection'; +import intersect, { parsePath } from 'path-intersection'; import { expect } from 'chai'; import domify from 'domify'; @@ -8,11 +8,11 @@ describe('path-intersection', function() { describe('api', function() { - var p1 = [ [ 'M', 0, 0 ], [ 'L', 100, 100 ] ]; - var p2 = 'M0,100L100,0'; + it('should support SVG and component paths', function() { - - it('should support SVG path and component args', function() { + // given + var p1 = Object.freeze([ [ 'M', 0, 0 ], [ 'L', 100, 100 ] ]); + var p2 = 'M0,100L100,0'; // when var intersections = intersect(p1, p2); @@ -24,6 +24,10 @@ describe('path-intersection', function() { it('should expose intersection', function() { + // given + var p1 = Object.freeze([ [ 'M', 0, 0 ], [ 'L', 100, 100 ] ]); + var p2 = 'M0,100L100,0'; + // when var intersection = intersect(p1, p2)[0]; @@ -38,6 +42,133 @@ describe('path-intersection', function() { expect(intersection.bez2).to.exist; }); + + it('should support pre-parsed paths', function() { + + // given + var curvePath = Object.freeze([ + [ 'M', 0, 0 ], + [ 'C', 0, 0, 100, 100, 100, 100 ] + ]); + + var absPath = Object.freeze([ + [ 'M', 0, 100 ], + [ 'L', 100, 0 ] + ]); + + // when + var intersections = intersect(curvePath, absPath); + + // then + expect(intersections).to.have.length(1); + expect(intersections[0].x).to.eql(50); + expect(intersections[0].y).to.eql(50); + }); + + + it('should support mixed pre-parsed and string paths', function() { + + // given + var curvePath = Object.freeze([ + [ 'M', 0, 0 ], + [ 'C', 0, 0, 100, 100, 100, 100 ] + ]); + var stringPath = 'M0,100L100,0'; + + // when + var intersections = intersect(curvePath, stringPath); + + // then + expect(intersections).to.have.length(1); + expect(intersections[0].x).to.eql(50); + expect(intersections[0].y).to.eql(50); + }); + + }); + + + describe('utility exports', function() { + + it('should export parsePath utility', function() { + + // given + var pathString = 'M0,0L100,100'; + + // when + var parsed = parsePath(pathString); + + // then + expect(parsed).to.eql([ + ['M', 0, 0], + ['C', 0, 0, 100, 100, 100, 100] + ]); + }); + + + it('should use parsePath with intersect', function() { + + // given + var p1 = parsePath('M0,0L100,100'); + var p2 = parsePath('M0,100L100,0'); + + // when + var intersections = intersect(p1, p2); + + // then + expect(intersections).to.have.length(1); + expect(intersections[0].x).to.eql(50); + expect(intersections[0].y).to.eql(50); + }); + + + it('should provide performance improvement with parsePath', function() { + + // given + var p1 = 'M123,50L243,150'; + var p2 = ( + 'M100,100l80,0' + + 'a10,10,0,0,1,10,10l0,60' + + 'a10,10,0,0,1,-10,10l-80,0' + + 'a10,10,0,0,1,-10,-10l0,-60' + + 'a10,10,0,0,1,10,-10z' + ); + + + // assume - warm up + timeParse(p1, p2, iterations); + + var results = []; + + // repeat a couple of times + for (var i = 0; i < 100; i++) { + + var iterations = 100; + + // when + var stringTime = timeParse(p1, p2, iterations); + var cachedTime = timeParse(parsePath(p1), parsePath(p1), iterations); + + // then + var speedup = stringTime / cachedTime; + + results.push({ + stringTime, + cachedTime, + speedup + }); + + expect(speedup).to.be.at.least(6); + } + + results.push({ + stringTime: results.reduce((sum, r) => sum + r.stringTime, 0) / results.length, + cachedTime: results.reduce((sum, r) => sum + r.cachedTime, 0) / results.length, + speedup: results.reduce((sum, r) => sum + r.speedup, 0) / results.length, + }); + + console.table(results); + }); + }); @@ -313,17 +444,20 @@ describe('path-intersection', function() { 'M423,497L423,180L300,262 M423,472l25,25l-25,25l-25,-25z M297,233l80,0a10,10,0,0,1,10,10l0,60a10,10,0,0,1,-10,10l-80,0a10,10,0,0,1,-10,-10l0,-60a10,10,0,0,1,10,-10z' ); + testScenario([ 'M10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80', 'M10 80 Q 95 10 180 80' ]); + testScenario([ 'M10 80 Q 52.5 10, 95 80 T 180 80', 'M10 315 L 110 215 A 30 50 0 0 1 162.55 162.45', 'M 100 150 L 172.55 152.45 A 30 50 -45 0 1 215.1 109.9 L 315 10' ]); + testScenario([ 'M30 80 A 45 45, 0, 0, 0, 75 125 L 75 80 Z', 'M30 80 A 45 45, 0, 1, 0, 75 125 L 75 80 Z', @@ -433,6 +567,15 @@ function testScenario(paths) { } +function timeParse(p1, p2, iterations) { + var start = performance.now(); + + for (var i = 0; i < iterations; i++) { + intersect(p1, p2); + } + return performance.now() - start; +} + function test(label, options) { createTest(it, label, options); } diff --git a/test/intersect.spec.ts b/test/intersect.spec.ts index 79f827c..342e715 100644 --- a/test/intersect.spec.ts +++ b/test/intersect.spec.ts @@ -1,4 +1,4 @@ -import intersect from 'path-intersection'; +import intersect, { parsePath, Path } from 'path-intersection'; import domify from 'domify'; @@ -7,7 +7,7 @@ describe('path-intersection', function() { describe('api', function() { - var p1 = [ [ 'M', 0, 0 ], [ 'L', 100, 100 ] ]; + var p1 = [ [ 'M', 0, 0 ], [ 'L', 100, 100 ] ] satisfies Path; var p2 = 'M0,100L100,0'; @@ -34,6 +34,26 @@ describe('path-intersection', function() { ]); }); + + describe('utility exports', function() { + + it('should export parsePath utility', function() { + + // given + var pathString = 'M0,0L100,100'; + + // when + var parsed = parsePath(pathString); + + // then + expect(parsed).to.eql([ + ['M', 0, 0], + ['C', 0, 0, 100, 100, 100, 100] + ]); + }); + + }); + }); }); \ No newline at end of file