From 74c7a5ba52fa1b6eb2a4aa389d32a280b7f28d19 Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Thu, 16 Oct 2025 17:34:52 +0300 Subject: [PATCH 01/16] refactor(`findBezierIntersections`): extract `bezierBBoxIntersects` --- intersect.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/intersect.js b/intersect.js index 5925720..aff7a7b 100644 --- a/intersect.js +++ b/intersect.js @@ -299,13 +299,14 @@ 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); +} + +function findBezierIntersections(bez1, bez2, justCount) { // As an optimization, lines will have only 1 segment @@ -448,7 +449,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; From f5455f9c6a996b20370156c82a1e13430bff4454 Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Thu, 16 Oct 2025 18:16:57 +0300 Subject: [PATCH 02/16] refactor(): `pathToCurve` `pathToAbsolute` `parsePathString` extract caching to `getPathCurve` --- intersect.js | 53 +++++++++++++++++++++++++++------------------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/intersect.js b/intersect.js index aff7a7b..8c19a18 100644 --- a/intersect.js +++ b/intersect.js @@ -75,12 +75,6 @@ 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 = []; @@ -114,7 +108,6 @@ function parsePathString(pathString) { } data.toString = paths.toString; - pth.arr = clone(data); return data; } @@ -407,8 +400,8 @@ function findBezierIntersections(bez1, bez2, justCount) { * @return {Array|Number} */ export default function findPathIntersections(path1, path2, justCount) { - path1 = pathToCurve(path1); - path2 = pathToCurve(path2); + path1 = getPathCurve(path1); + path2 = getPathCurve(path2); var x1, y1, x2, y2, x1m, y1m, x2m, y2m, bez1, bez2, res = justCount ? 0 : []; @@ -476,15 +469,6 @@ export default function findPathIntersections(path1, path2, justCount) { 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 ] ]; @@ -567,7 +551,6 @@ function pathToAbsolute(pathArray) { } res.toString = pathToString; - pth.abs = pathClone(res); return res; } @@ -788,16 +771,39 @@ function curveBBox(x0, y0, x1, y1, x2, y2, x3, y3) { }; } -function pathToCurve(path) { +/** + * Handles caches + */ +function getPathCurve(path) { - var pth = paths(path); + const pth = paths(path); // return cached curve, if existing if (pth.curve) { return pathClone(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 pathClone(pth.curve = pathToCurve(abs)); +} + +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; @@ -921,8 +927,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 From a773a2e155da5e7255d93f0797ff73322fc5fb9f Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Thu, 16 Oct 2025 19:57:25 +0300 Subject: [PATCH 03/16] refactor(): rm `clone` usages --- intersect.js | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/intersect.js b/intersect.js index 8c19a18..84b5cf6 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]); @@ -78,10 +61,6 @@ function parsePathString(pathString) { 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) { @@ -159,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; } From 86917e3d0b379550d8612eb73399a5976351afbf Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Sun, 19 Oct 2025 10:55:30 +0300 Subject: [PATCH 04/16] feat(): rm redundant `pathClone` call --- intersect.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/intersect.js b/intersect.js index 84b5cf6..8c1c37a 100644 --- a/intersect.js +++ b/intersect.js @@ -768,7 +768,7 @@ function getPathCurve(path) { // return cached curve, if existing if (pth.curve) { - return pathClone(pth.curve); + return pth.curve; } // retrieve abs path OR create and cache if non existing @@ -786,7 +786,7 @@ function getPathCurve(path) { )); // cache curve - return pathClone(pth.curve = pathToCurve(abs)); + return (pth.curve = pathToCurve(abs)); } function pathToCurve(absPath) { From 06bcefe9261b4bfb228c90a423078e60caf947db Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Sun, 19 Oct 2025 10:54:51 +0300 Subject: [PATCH 05/16] docs(): jsdoc fixes --- intersect.d.ts | 3 +-- intersect.js | 16 +++++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/intersect.d.ts b/intersect.d.ts index 7b764db..567d517 100644 --- a/intersect.d.ts +++ b/intersect.d.ts @@ -1,4 +1,3 @@ - /** * Find or counts the intersections between two SVG paths. * @@ -48,7 +47,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 8c1c37a..a8315d1 100644 --- a/intersect.js +++ b/intersect.js @@ -287,6 +287,13 @@ function bezierBBoxIntersects(bez1, bez2) { 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 @@ -381,11 +388,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 {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 = getPathCurve(path1); @@ -455,7 +462,6 @@ export default function findPathIntersections(path1, path2, justCount) { return res; } - function pathToAbsolute(pathArray) { if (!pathArray || !pathArray.length) { From 9cfa86347e3e507daf369c9f070da89ccd2b5c45 Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Sun, 19 Oct 2025 11:22:18 +0300 Subject: [PATCH 06/16] feat(): `parsePathCurve` --- intersect.d.ts | 12 ++++++++++++ intersect.js | 36 +++++++++++++++++++++++++++++++++--- test/intersect.spec.js | 17 ++++++++++++++++- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/intersect.d.ts b/intersect.d.ts index 567d517..2a2bf09 100644 --- a/intersect.d.ts +++ b/intersect.d.ts @@ -35,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: diff --git a/intersect.js b/intersect.js index a8315d1..25e5010 100644 --- a/intersect.js +++ b/intersect.js @@ -395,8 +395,8 @@ function findBezierIntersections(bez1, bez2, justCount) { * @return {import("./intersect").Intersection[] | number} */ export default function findPathIntersections(path1, path2, justCount) { - path1 = getPathCurve(path1); - path2 = getPathCurve(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 : []; @@ -766,7 +766,7 @@ function curveBBox(x0, y0, x1, y1, x2, y2, x3, y3) { } /** - * Handles caches + * An impure version of {@link parsePathCurve} handling caching */ function getPathCurve(path) { @@ -795,6 +795,36 @@ function getPathCurve(path) { 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), 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() { From fba13064a66542f80448171f6c5facd1903a6910 Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Mon, 20 Oct 2025 15:45:08 +0300 Subject: [PATCH 07/16] fix(): failing ts test --- test/intersect.spec.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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() { From a19e9a67aab56d83500c60ce546236e141630573 Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Mon, 20 Oct 2025 15:45:26 +0300 Subject: [PATCH 08/16] test(): test no mutation --- test/intersect.spec.js | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/test/intersect.spec.js b/test/intersect.spec.js index b1a3fcd..f92ff8a 100644 --- a/test/intersect.spec.js +++ b/test/intersect.spec.js @@ -24,8 +24,8 @@ describe('path-intersection', function() { it('parsePathCurve', function() { // when - var parsed1 = parsePathCurve(p1); - var parsed2 = parsePathCurve(p2); + const parsed1 = parsePathCurve(p1); + const parsed2 = parsePathCurve(p2); // then expect(parsed1).to.deep.eq([['M', 0, 0], ['C', 0, 0, 100, 100, 100, 100]]) @@ -34,6 +34,22 @@ describe('path-intersection', function() { 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]]) + }); From ef16424947875bee46a614f3e951ebd4203af9a2 Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Tue, 21 Oct 2025 08:30:32 +0300 Subject: [PATCH 09/16] test(): caching --- test/intersect.spec.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/intersect.spec.js b/test/intersect.spec.js index f92ff8a..ae7ebfb 100644 --- a/test/intersect.spec.js +++ b/test/intersect.spec.js @@ -21,6 +21,30 @@ 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 From c51c3f671035ff38c4d533464fa84e1b7cd6db16 Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Tue, 21 Oct 2025 08:36:52 +0300 Subject: [PATCH 10/16] chore(): inline `bezierBBoxIntersects` --- intersect.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/intersect.js b/intersect.js index 25e5010..b3cc26d 100644 --- a/intersect.js +++ b/intersect.js @@ -280,13 +280,6 @@ function fixError(number) { return Math.round(number * 100000000000) / 100000000000; } -function bezierBBoxIntersects(bez1, bez2) { - var bbox1 = bezierBBox(bez1), - bbox2 = bezierBBox(bez2); - - return isBBoxIntersect(bbox1, bbox2); -} - /** * * @param {import("./intersect").PathComponent} bez1 @@ -398,7 +391,7 @@ export default function findPathIntersections(path1, path2, justCount) { 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++) { @@ -437,7 +430,10 @@ export default function findPathIntersections(path1, path2, justCount) { y2 = y2m; } - var intr = bezierBBoxIntersects(bez1, bez2) ? + bbox1 = bezierBBox(bez1); + bbox2 = bezierBBox(bez2); + + var intr = isBBoxIntersect(bbox1, bbox2) ? findBezierIntersections(bez1, bez2, justCount) : justCount ? 0 : []; From 6772ff58026f36582700f48021f829bd4aeae63f Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Tue, 21 Oct 2025 09:02:50 +0300 Subject: [PATCH 11/16] perf(): memory alloc --- intersect.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/intersect.js b/intersect.js index b3cc26d..e20dbf2 100644 --- a/intersect.js +++ b/intersect.js @@ -297,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++) { From 18b82b45b92802931e0196a655a8a59fa73aeccb Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Tue, 28 Oct 2025 06:07:20 +0200 Subject: [PATCH 12/16] fix(): disable flaky test --- test/intersect.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/intersect.spec.js b/test/intersect.spec.js index ae7ebfb..dbb7217 100644 --- a/test/intersect.spec.js +++ b/test/intersect.spec.js @@ -21,7 +21,7 @@ describe('path-intersection', function() { expect(intersections).to.have.length(1); }); - it('should cache paths to boost performance', function() { + it.skip('should cache paths to boost performance', function() { const max = 1000; const p1 = [ @@ -42,7 +42,6 @@ describe('path-intersection', function() { const { duration: b } = performance.measure('cached', 'b'); // then expect(b).to.lessThanOrEqual(a); - expect(rb).to.deep.eq(ra); }); it('parsePathCurve', function() { @@ -71,6 +70,7 @@ describe('path-intersection', function() { } ]) + 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]]) From 96428cba26b06d62bfe77bfd58752d4705203fcf Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Tue, 28 Oct 2025 06:37:53 +0200 Subject: [PATCH 13/16] fix(): better types --- intersect.d.ts | 4 +++- test/intersect.spec.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/intersect.d.ts b/intersect.d.ts index 2a2bf09..66955a9 100644 --- a/intersect.d.ts +++ b/intersect.d.ts @@ -59,7 +59,9 @@ export declare function parsePathCurve(path: Path): PathComponent[] * ] */ declare type Path = string | PathComponent[]; -declare type PathComponent = [cmd: string, ...number[]]; +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/test/intersect.spec.ts b/test/intersect.spec.ts index 3b47ca7..2889481 100644 --- a/test/intersect.spec.ts +++ b/test/intersect.spec.ts @@ -5,8 +5,8 @@ describe('path-intersection', function() { describe('api', function() { - const p1: Path = [ [ 'M', 0, 0 ], [ 'L', 100, 100 ] ]; - const p2: Path = '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() { From 3b51d610e2b9e5461cdbae82d544f8ce511eb5de Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Tue, 28 Oct 2025 06:38:54 +0200 Subject: [PATCH 14/16] fix(): update karma config for macOS https://discussions.apple.com/thread/253741610 --- karma.conf.cjs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) 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', + ] + } } + }); }; From 8d3f7088cdc880bde1d494aa66a7f38f6b57a8d8 Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Fri, 17 Oct 2025 15:15:23 +0300 Subject: [PATCH 15/16] feat(): spatial indexing --- indexing.d.ts | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++ indexing.js | 81 +++++++++++++++++++++++++++++++++++++++++++++ intersect.js | 2 +- 3 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 indexing.d.ts create mode 100644 indexing.js 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.js b/intersect.js index e20dbf2..a1ee17b 100644 --- a/intersect.js +++ b/intersect.js @@ -684,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; From 46a1f90e8c05873c280bfa8871f9349d1b2956b4 Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Fri, 17 Oct 2025 17:39:18 +0300 Subject: [PATCH 16/16] test(): add test file template --- test/perf.js | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 test/perf.js 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());