diff --git a/intersect.d.ts b/intersect.d.ts index 7b764db..2a2bf09 100644 --- a/intersect.d.ts +++ b/intersect.d.ts @@ -1,4 +1,3 @@ - /** * Find or counts the intersections between two SVG paths. * @@ -36,6 +35,18 @@ declare function findPathIntersections(path1: Path, path2: Path, justCount?: boo export default findPathIntersections; +/** + * Parse a path so it is suitable to pass to {@link findPathIntersections} + * Used in order to opt out of internal path caching. + * + * @example + * const p1 = parsePathCurve('M0,0L100,100'); + * const p2 = parsePathCurve([ [ 'M', 0, 100 ], [ 'L', 100, 0 ] ]); + * const intersections = findPathIntersections(p1, p2); + * + */ +export declare function parsePathCurve(path: Path): PathComponent[] + /** * A string in the form of 'M150,150m0,-18a18,18,0,1,1,0,36a18,18,0,1,1,0,-36z' * or something like: @@ -48,7 +59,7 @@ export default findPathIntersections; * ] */ declare type Path = string | PathComponent[]; -declare type PathComponent = any[]; +declare type PathComponent = [cmd: string, ...number[]]; declare interface Intersection { /** diff --git a/intersect.js b/intersect.js index 5925720..e20dbf2 100644 --- a/intersect.js +++ b/intersect.js @@ -23,23 +23,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]); @@ -75,19 +58,9 @@ function parsePathString(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) { String(pathString).replace(pathCommand, function(a, b, c) { @@ -114,7 +87,6 @@ function parsePathString(pathString) { } data.toString = paths.toString; - pth.arr = clone(data); return data; } @@ -166,7 +138,16 @@ function pathToString() { } function pathClone(pathArray) { - var res = clone(pathArray); + const res = new Array(pathArray.length); + for (let i = 0; i < pathArray.length; i++) { + const sourceRow = pathArray[i]; + const destinationRow = new Array(sourceRow.length); + res[i] = destinationRow; + for (let j = 0; j < sourceRow.length; j++) { + destinationRow[j] = sourceRow[j]; + } + } + res.toString = pathToString; return res; } @@ -299,13 +280,14 @@ function fixError(number) { return Math.round(number * 100000000000) / 100000000000; } +/** + * + * @param {import("./intersect").PathComponent} bez1 + * @param {import("./intersect").PathComponent} bez2 + * @param {boolean} [justCount=false] + * @returns {import("./intersect").Intersection} + */ function findBezierIntersections(bez1, bez2, justCount) { - var bbox1 = bezierBBox(bez1), - bbox2 = bezierBBox(bez2); - - if (!isBBoxIntersect(bbox1, bbox2)) { - return justCount ? 0 : []; - } // As an optimization, lines will have only 1 segment @@ -315,19 +297,19 @@ function findBezierIntersections(bez1, bez2, justCount) { n1 = isLine(bez1) ? 1 : ~~(l1 / 5) || 1, // eslint-disable-next-line no-bitwise n2 = isLine(bez2) ? 1 : ~~(l2 / 5) || 1, - dots1 = [], - dots2 = [], + dots1 = new Array(n1 + 1), + dots2 = new Array(n2 + 1), xy = {}, res = justCount ? 0 : []; for (var i = 0; i < n1 + 1; i++) { var p = findDotsAtSegment(...bez1, i / n1); - dots1.push({ x: p.x, y: p.y, t: i / n1 }); + dots1[i] = { x: p.x, y: p.y, t: i / n1 }; } for (i = 0; i < n2 + 1; i++) { p = findDotsAtSegment(...bez2, i / n2); - dots2.push({ x: p.x, y: p.y, t: i / n2 }); + dots2[i] = { x: p.x, y: p.y, t: i / n2 }; } for (i = 0; i < n1; i++) { @@ -399,17 +381,17 @@ 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 {import("./intersect").Path} path1 + * @param {import("./intersect").Path} path2 + * @param {boolean} [justCount=false] * - * @return {Array|Number} + * @return {import("./intersect").Intersection[] | number} */ export default function findPathIntersections(path1, path2, justCount) { - path1 = pathToCurve(path1); - path2 = pathToCurve(path2); + path1 = path1.parsed ? path1 : getPathCurve(path1); + path2 = path2.parsed ? path2 : getPathCurve(path2); - var x1, y1, x2, y2, x1m, y1m, x2m, y2m, bez1, bez2, + var x1, y1, x2, y2, x1m, y1m, x2m, y2m, bez1, bez2, bbox1, bbox2, res = justCount ? 0 : []; for (var i = 0, ii = path1.length; i < ii; i++) { @@ -448,7 +430,12 @@ export default function findPathIntersections(path1, path2, justCount) { y2 = y2m; } - var intr = findBezierIntersections(bez1, bez2, justCount); + bbox1 = bezierBBox(bez1); + bbox2 = bezierBBox(bez2); + + var intr = isBBoxIntersect(bbox1, bbox2) ? + findBezierIntersections(bez1, bez2, justCount) : + justCount ? 0 : []; if (justCount) { res += intr; @@ -471,17 +458,7 @@ export default function findPathIntersections(path1, path2, justCount) { return res; } - 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); - } if (!pathArray || !pathArray.length) { return [ [ 'M', 0, 0 ] ]; @@ -564,7 +541,6 @@ function pathToAbsolute(pathArray) { } res.toString = pathToString; - pth.abs = pathClone(res); return res; } @@ -785,16 +761,69 @@ function curveBBox(x0, y0, x1, y1, x2, y2, x3, y3) { }; } -function pathToCurve(path) { +/** + * An impure version of {@link parsePathCurve} handling caching + */ +function getPathCurve(path) { - var pth = paths(path); + const pth = paths(path); // return cached curve, if existing if (pth.curve) { - return pathClone(pth.curve); + return pth.curve; } - var curvedPath = pathToAbsolute(path), + // retrieve abs path OR create and cache if non existing + const abs = pth.abs || + (pth.abs = pathToAbsolute( + + // retrieve parsed path OR create and cache if non existing + pth.arr || + (pth.arr = ( + + // rough assumption + (!isArray(path) || !isArray(path && path[0]))) ? + parsePathString(path) : + path) + )); + + // cache curve + return (pth.curve = pathToCurve(abs)); +} + +/** + * A pure version of {@link getPathCurve} + * @param {import("./intersect").Path} path + * @returns {import("./intersect").PathComponent[]} + */ +export function parsePathCurve(path) { + + const abs = (pathToAbsolute( + !Array.isArray(path) ? + parsePathString(path) : + path) + ); + + const curve = pathToCurve(abs); + + /** + * Flag to skip {@link getPathCurve} + */ + return Object.defineProperty( + curve, + 'parsed', + { + value: true, + configurable: false, + enumerable: false, + writable: false + } + ); +} + +function pathToCurve(absPath) { + + var curvedPath = pathClone(absPath), 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 +947,5 @@ function pathToCurve(path) { attrs.by = toFloat(seg[seglen - 3]) || attrs.y; } - // cache curve - pth.curve = pathClone(curvedPath); - return curvedPath; } \ No newline at end of file diff --git a/test/intersect.spec.js b/test/intersect.spec.js index a4bffbe..ae7ebfb 100644 --- a/test/intersect.spec.js +++ b/test/intersect.spec.js @@ -1,4 +1,4 @@ -import intersect from 'path-intersection'; +import intersect, { parsePathCurve } from 'path-intersection'; import { expect } from 'chai'; import domify from 'domify'; @@ -21,6 +21,61 @@ describe('path-intersection', function() { expect(intersections).to.have.length(1); }); + it('should cache paths to boost performance', function() { + + const max = 1000; + const p1 = [ + [ 'M', 0, 0 ], + ...new Array(max).fill(0).map((_, i) => [ 'L', max * (i + 1), max * (i + 1) ]) + ]; + const p2 = [ + [ 'M', 0, max * max ], + ...new Array(max).fill(0).map((_, i) => [ 'L', max * (i + 1), max * (max - i + 1) ]) + ].flat().join(','); + + // when + performance.mark('a') + const ra = intersect(p1, p2); + const { duration: a } = performance.measure('not cached', 'a'); + performance.mark('b') + const rb = intersect(p1, p2); + const { duration: b } = performance.measure('cached', 'b'); + // then + expect(b).to.lessThanOrEqual(a); + expect(rb).to.deep.eq(ra); + }); + + it('parsePathCurve', function() { + + // when + const parsed1 = parsePathCurve(p1); + const parsed2 = parsePathCurve(p2); + + // then + expect(parsed1).to.deep.eq([['M', 0, 0], ['C', 0, 0, 100, 100, 100, 100]]) + expect(parsed1.parsed).to.eq(true) + + expect(parsed2).to.deep.eq([['M', 0, 100], ['C', 0, 100, 100, 0, 100, 0]]) + expect(parsed2.parsed).to.eq(true) + + expect(intersect(parsed1, parsed2)).to.deep.eq([ + { + x: 50, + y: 50, + segment1: 1, + segment2: 1, + t1: 0.5, + t2: 0.5, + bez1: [0, 0, 0, 0, 100, 100, 100, 100], + bez2: [0, 100, 0, 100, 100, 0, 100, 0] + } + ]) + + expect(parsed1, 'intersect should not mutate paths').to.deep.eq([['M', 0, 0], ['C', 0, 0, 100, 100, 100, 100]]) + expect(parsed2, 'intersect should not mutate paths').to.deep.eq([['M', 0, 100], ['C', 0, 100, 100, 0, 100, 0]]) + + }); + it('should expose intersection', function() { diff --git a/test/intersect.spec.ts b/test/intersect.spec.ts index 79f827c..3b47ca7 100644 --- a/test/intersect.spec.ts +++ b/test/intersect.spec.ts @@ -1,14 +1,12 @@ -import intersect from 'path-intersection'; - -import domify from 'domify'; +import intersect, { Path } from 'path-intersection'; describe('path-intersection', function() { describe('api', function() { - var p1 = [ [ 'M', 0, 0 ], [ 'L', 100, 100 ] ]; - var p2 = 'M0,100L100,0'; + const p1: Path = [ [ 'M', 0, 0 ], [ 'L', 100, 100 ] ]; + const p2: Path = 'M0,100L100,0'; it('should support SVG path and component args', function() {