diff --git a/indexing.d.ts b/indexing.d.ts new file mode 100644 index 0000000..0f826ce --- /dev/null +++ b/indexing.d.ts @@ -0,0 +1,92 @@ +import type { Path, PathComponent, Intersection as Intersection0 } from './intersect.d.ts'; + +export type Intersection = Intersection0 & { + id1: number; + id2: number; +}; + +export type BBox = { x0: number; y0: number; x1: number; y1: number }; + +export type IndexEntry = { + pathId: number; + curveIndex: number; + curve: PathComponent; +}; + +export type IndexIntersection = [IndexEntry, IndexEntry]; + +export interface SpatialIndex { + add( + pathId: number, + curveIndex: number, + curve: PathComponent, + bbox: BBox + ): void; + + remove(pathId: number): void; + + intersect(pathIds: number[]): IndexIntersection[]; +} + +/** + * Index {@link path} into {@link spatialIndex} + * Must be called before {@link findPathIntersections} + * @returns index key to pass to {@link findPathIntersections} + */ +export function indexPath(path: Path, spatialIndex: SpatialIndex): number; + +/** + * Find or counts the intersections between two SVG paths. + * + * Returns a number in counting mode and a list of intersections otherwise. + * + * A single intersection entry contains the intersection coordinates (x, y) + * as well as additional information regarding the intersecting segments + * 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 + * such as `[ [ 'M', 0, 10 ], [ 'L', 20, 0 ] ]`. + * + * Uses spatial indexing to boost performance. + * If a path is not indexed the method will return no intersections. + * @see {@link indexPath} + * + * @example + * + * const spatialIndex = new SpatialIndex(); + * const id1 = indexPath('M0,0L100,100', spatialIndex); + * const id2 = indexPath([ [ 'M', 0, 100 ], [ 'L', 100, 0 ] ], spatialIndex); + * const id3 = indexPath([ [ 'M', 0, 50 ], [ 'L', 100, 50 ] ], spatialIndex); + * + * const intersections = findPathIntersections(id1, id2, spatialIndex, false); + * const intersections2 = findPathIntersections(id1, id3, spatialIndex, false); + * + * // intersections = [ + * // { x: 50, y: 50, segment1: 1, segment2: 1, t1: 0.5, t2: 0.5 } + * // ]; + * // intersections2 = [ + * // { x: 50, y: 50, segment1: 1, segment2: 1, t1: 0.5, t2: 0.5 } + * // ]; + */ +declare function findPathIntersections( + pathIds: number[], + index: SpatialIndex, + justCount: true +): number; +declare function findPathIntersections( + pathIds: number[], + index: SpatialIndex, + justCount: false +): Intersection[]; +declare function findPathIntersections( + pathIds: number[], + index: SpatialIndex +): Intersection[]; +declare function findPathIntersections( + pathIds: number[], + index: SpatialIndex, + justCount?: boolean +): Intersection[] | number; + +export default findPathIntersections; diff --git a/indexing.js b/indexing.js new file mode 100644 index 0000000..3b6855b --- /dev/null +++ b/indexing.js @@ -0,0 +1,81 @@ +import { curveBBox, findBezierIntersections, parsePathCurve } from './intersect.js'; + +let indexKey = 0; + +/** + * + * @param {import('./intersect').Path[]} path + * @param {import('./indexing').SpatialIndex} index + * @returns {number} index key + */ +export function indexPath(path, index) { + const curve = parsePathCurve(path); + + const pathId = indexKey++; + + for ( + let curveIndex = 0, x1, y1, x1m, y1m, bez, pi; + curveIndex < curve.length; + curveIndex++ + ) { + pi = curve[curveIndex]; + + if (pi[0] == 'M') { + x1 = x1m = pi[1]; + y1 = y1m = pi[2]; + } else { + if (pi[0] == 'C') { + bez = [ x1, y1, ...pi.slice(1) ]; + x1 = bez[6]; + y1 = bez[7]; + } else { + bez = [ x1, y1, x1, y1, x1m, y1m, x1m, y1m ]; + x1 = x1m; + y1 = y1m; + } + + index.add(pathId, curveIndex, bez, curveBBox(...bez)); + } + } + + return pathId; +} + +/** + * + * @param {number[]} pathIds + * @param {import('./indexing').SpatialIndex} index + * @param {boolean} [justCount] + */ +export default function findIndexedPathIntersections( + pathIds, + index, + justCount +) { + let res = justCount ? 0 : []; + + index.intersect(pathIds).forEach(([ a, b ]) => { + + /** + * @type {import('./indexing.js').Intersection[]} + */ + const intr = findBezierIntersections(a.curve, b.curve, justCount); + + if (justCount) { + res += intr; + } else { + for (var k = 0, kk = intr.length; k < kk; k++) { + intr[k].id1 = a.pathId; + intr[k].id2 = b.pathId; + intr[k].segment1 = a.curveIndex; + intr[k].segment2 = b.curveIndex; + intr[k].bez1 = a.curve; + intr[k].bez2 = b.curve; + } + + res = res.concat(intr); + } + }); + + return res; +} diff --git a/intersect.d.ts b/intersect.d.ts index 7b764db..66955a9 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,9 @@ export default findPathIntersections; * ] */ declare type Path = string | PathComponent[]; -declare type PathComponent = any[]; +declare type RelativePathCmd = 'a' | 'c' | 'h' | 'l' | 'm' | 'q' | 's' | 't' | 'v' | 'z'; +declare type AbsolutePathCmd = Capitalize; +declare type PathComponent = [cmd: AbsolutePathCmd | RelativePathCmd, ...number[]]; declare interface Intersection { /** diff --git a/intersect.js b/intersect.js index 5925720..a1ee17b 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; } @@ -708,7 +684,7 @@ function arcToCurve(x1, y1, rx, ry, angle, large_arc_flag, sweep_flag, x2, y2, r // Source: http://blog.hackers-cafe.net/2009/06/how-to-calculate-bezier-curves-bounding.html // Original version: NISHIO Hirokazu // Modifications: https://github.com/timo22345 -function curveBBox(x0, y0, x1, y1, x2, y2, x3, y3) { +export function curveBBox(x0, y0, x1, y1, x2, y2, x3, y3) { var tvalues = [], bounds = [ [], [] ], a, b, c, t, t1, t2, b2ac, sqrtb2ac; @@ -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/karma.conf.cjs b/karma.conf.cjs index 4ba9cdf..53efa27 100644 --- a/karma.conf.cjs +++ b/karma.conf.cjs @@ -1,13 +1,12 @@ /** eslint-env node */ // configures browsers to run test against -// any of [ 'ChromeHeadless', 'Chrome', 'Firefox' ] -const browsers = (process.env.TEST_BROWSERS || 'ChromeHeadless').split(','); +// any of [ 'ChromeHeadless', 'ChromeHeadlessDev', 'Chrome', 'ChromeDev', 'Firefox' ] +const browsers = (process.env.TEST_BROWSERS || 'ChromeHeadlessDev').split(','); // use puppeteer provided Chrome for testing process.env.CHROME_BIN = require('puppeteer').executablePath(); - module.exports = function(karma) { karma.set({ @@ -32,6 +31,26 @@ module.exports = function(karma) { webpack: { mode: 'development', devtool: 'eval-source-map' + }, + + customLaunchers: { + ChromeDev: { + base: 'Chrome', + displayName: 'ChromeDev', + flags: [ + // disable chromium safe storage access request security prompt on macOS + '--use-mock-keychain', + ] + }, + ChromeHeadlessDev: { + base: 'ChromeHeadless', + displayName: 'ChromeHeadlessDev', + flags: [ + // disable chromium safe storage access request security prompt on macOS + '--use-mock-keychain', + ] + } } + }); }; diff --git a/test/intersect.spec.js b/test/intersect.spec.js index a4bffbe..dbb7217 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.skip('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); + }); + + 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(intersect(parsed1, parsed2), 'intersect should not mutate paths').to.deep.eq(intersect(parsed1, parsed2)); + 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..2889481 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 ] ] satisfies Path; + const p2: Path = 'M0,100L100,0' satisfies Path; it('should support SVG path and component args', function() { diff --git a/test/perf.js b/test/perf.js new file mode 100644 index 0000000..67e00db --- /dev/null +++ b/test/perf.js @@ -0,0 +1,94 @@ +import intersect from '../intersect.js'; +import intersect2, { indexPath } from '../indexing.js'; + +/** + * Needs to be implemented, probably a simple nested grid or a quad tree will do + * @typedef {import('../indexing.js').SpatialIndex} Interface + * @type {import('../indexing.js').SpatialIndex} + * @implements {Interface} + */ +class SpatialIndex { + /** + * + * @param {number} pathId + * @param {number} curveIndex + * @param {import('../intersect.js').PathComponent} curve + * @param {import('../indexing.js').BBox} bbox + */ + add(pathId, curveIndex, curve, bbox) { + throw new Error('Method not implemented.'); + } + + /** + * + * @param {number} pathId + */ + remove(pathId) { + throw new Error('Method not implemented.'); + } + + /** + * + * @param {number[]} pathIds + */ + intersect(pathIds) { + throw new Error('Method not implemented.'); + return []; + } +} + +/** + * + * @param {number} n + * @returns {import('../intersect.js').PathComponent[]} + */ +const createPath = (n) => { + /** + * + * @param {'M'|'L'} cmd + * @returns {import('../intersect.js').PathComponent} + */ + const cmd = (cmd) => [ + cmd, + Math.round(Math.random() * 800), + Math.round(Math.random() * 800), + ]; + return [cmd('M')].concat(new Array(n).fill(0).map(() => cmd('L'))); +}; + +const a = createPath(5000); +const b = createPath(5000); + +const index = new SpatialIndex(); + +// when + +performance.mark('total'); +performance.mark('index'); +const id1 = indexPath(a, index); +const id2 = indexPath(b, index); +const mark0 = performance.measure( + 'indexing', + { detail: { ids: [id1, id2] } }, + 'index' +); +performance.mark('intersect2'); +const intersections = intersect2([id1, id2], index).length; +const mark1 = performance.measure( + 'intersect', + { detail: { intersections } }, + 'intersect2' +); +const mark = performance.measure('intersect2 total', 'total'); + +console.log(mark0.toJSON(), mark1.toJSON(), mark.toJSON()); + +performance.mark('intersect'); +const baseline = intersect(a, b, true); +const baselineMark = performance.measure( + 'baseline', + { detail: { intersections: baseline } }, + 'intersect' +); + +console.log(baselineMark.toJSON());