diff --git a/indexing.d.ts b/indexing.d.ts new file mode 100644 index 0000000..0fc38c0 --- /dev/null +++ b/indexing.d.ts @@ -0,0 +1,92 @@ +export type Intersection = import('./intersect.d.ts').Intersection & { + id1: number; + id2: number; +}; +type Path = import('./intersect.d.ts').Path; +type PathComponent = import('./intersect.d.ts').PathComponent; + +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..39afaaa --- /dev/null +++ b/indexing.js @@ -0,0 +1,81 @@ +import { curveBBox, findBezierIntersections, parsePathCurve } from './intersect.js'; + +let indexKey = 0; + +/** + * + * @param {string | 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..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..b97186b 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,21 @@ function fixError(number) { return Math.round(number * 100000000000) / 100000000000; } -function findBezierIntersections(bez1, bez2, justCount) { +function bezierBBoxIntersects(bez1, bez2) { var bbox1 = bezierBBox(bez1), bbox2 = bezierBBox(bez2); - if (!isBBoxIntersect(bbox1, bbox2)) { - return justCount ? 0 : []; - } + return isBBoxIntersect(bbox1, bbox2); +} + +/** + * + * @param {import("./intersect").PathComponent} bez1 + * @param {import("./intersect").PathComponent} bez2 + * @param {boolean} [justCount=false] + * @returns {import("./intersect").Intersection} + */ +function findBezierIntersections(bez1, bez2, justCount) { // As an optimization, lines will have only 1 segment @@ -399,15 +388,15 @@ 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, res = justCount ? 0 : []; @@ -448,7 +437,9 @@ export default function findPathIntersections(path1, path2, justCount) { y2 = y2m; } - var intr = findBezierIntersections(bez1, bez2, justCount); + var intr = bezierBBoxIntersects(bez1, bez2) ? + findBezierIntersections(bez1, bez2, justCount) : + justCount ? 0 : []; if (justCount) { res += intr; @@ -471,17 +462,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 +545,6 @@ function pathToAbsolute(pathArray) { } res.toString = pathToString; - pth.abs = pathClone(res); return res; } @@ -708,7 +688,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 +765,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 +951,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..b1a3fcd 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,21 @@ describe('path-intersection', function() { expect(intersections).to.have.length(1); }); + it('parsePathCurve', function() { + + // when + var parsed1 = parsePathCurve(p1); + var 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) + + }); + it('should expose intersection', 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());