From 8a358cb73dc9466064602c64c195a0cd3a398731 Mon Sep 17 00:00:00 2001 From: Nico Rehwaldt Date: Sun, 20 Sep 2026 20:27:04 +0200 Subject: [PATCH 1/3] perf: precompute segments and bounding boxes per path Walk each path once, extracting bezier segments with their bounding boxes, instead of re-walking path2 and recomputing every bezier bounding box for each segment pair. Non-overlapping segment pairs are now skipped via a cheap bounding box check before the expensive subdivision-based intersection. Also push results instead of repeatedly concat()-ing arrays. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- intersect.js | 128 ++++++++++++++++++++++++++++----------------------- 1 file changed, 71 insertions(+), 57 deletions(-) diff --git a/intersect.js b/intersect.js index 1bec192..e608ca7 100644 --- a/intersect.js +++ b/intersect.js @@ -219,8 +219,6 @@ function isPointInsideBBox(bbox, x, y) { } function isBBoxIntersect(bbox1, bbox2) { - bbox1 = rectBBox(bbox1); - bbox2 = rectBBox(bbox2); return isPointInsideBBox(bbox2, bbox1.x, bbox1.y) || isPointInsideBBox(bbox2, bbox1.x2, bbox1.y) || isPointInsideBBox(bbox2, bbox1.x, bbox1.y2) @@ -312,9 +310,9 @@ function fixError(number) { return Math.round(number * 100000000000) / 100000000000; } -function findBezierIntersections(bez1, bez2, justCount) { - var bbox1 = bezierBBox(bez1), - bbox2 = bezierBBox(bez2); +function findBezierIntersections(bez1, bez2, justCount, bbox1, bbox2) { + bbox1 = bbox1 || bezierBBox(bez1); + bbox2 = bbox2 || bezierBBox(bez2); if (!isBBoxIntersect(bbox1, bbox2)) { return justCount ? 0 : []; @@ -335,13 +333,15 @@ function findBezierIntersections(bez1, bez2, justCount) { i, j; for (i = 0; i < n1 + 1; i++) { - var p = findDotsAtSegment(...bez1, i / n1); - dots1[i] = { x: p.x, y: p.y, t: i / n1 }; + var t = i / n1, + p = findDotsAtSegment(...bez1, t); + dots1[i] = { x: p.x, y: p.y, t: t }; } for (i = 0; i < n2 + 1; i++) { - p = findDotsAtSegment(...bez2, i / n2); - dots2[i] = { x: p.x, y: p.y, t: i / n2 }; + t = i / n2; + p = findDotsAtSegment(...bez2, t); + dots2[i] = { x: p.x, y: p.y, t: t }; } for (i = 0; i < n1; i++) { @@ -425,63 +425,33 @@ function findBezierIntersections(bez1, bez2, justCount) { * @return {Intersection[]|number} */ export default function findPathIntersections(path1, path2, justCount) { - path1 = pathToCurve(path1); - path2 = pathToCurve(path2); - - var x1, y1, x2, y2, x1m, y1m, x2m, y2m, bez1, bez2, + var segments1 = pathToSegments(pathToCurve(path1)), + segments2 = pathToSegments(pathToCurve(path2)), res = justCount ? 0 : []; - for (var i = 0, ii = path1.length; i < ii; i++) { - var pi = path1[i]; + for (var i = 0, ii = segments1.length; i < ii; i++) { + var s1 = segments1[i]; - if (pi[0] == 'M') { - x1 = x1m = pi[1]; - y1 = y1m = pi[2]; - } else { + for (var j = 0, jj = segments2.length; j < jj; j++) { + var s2 = segments2[j]; - if (pi[0] == 'C') { - bez1 = [ x1, y1, ...pi.slice(1) ]; - x1 = bez1[6]; - y1 = bez1[7]; - } else { - bez1 = [ x1, y1, x1, y1, x1m, y1m, x1m, y1m ]; - x1 = x1m; - y1 = y1m; + if (!isBBoxIntersect(s1.bbox, s2.bbox)) { + continue; } - for (var j = 0, jj = path2.length; j < jj; j++) { - var pj = path2[j]; - - if (pj[0] == 'M') { - x2 = x2m = pj[1]; - y2 = y2m = pj[2]; - } else { - - if (pj[0] == 'C') { - bez2 = [ x2, y2, ...pj.slice(1) ]; - x2 = bez2[6]; - y2 = bez2[7]; - } else { - bez2 = [ x2, y2, x2, y2, x2m, y2m, x2m, y2m ]; - x2 = x2m; - y2 = y2m; - } - - var intr = findBezierIntersections(bez1, bez2, justCount); + var intr = findBezierIntersections(s1.bez, s2.bez, justCount, s1.bbox, s2.bbox); - if (justCount) { - res += intr; - } else { + if (justCount) { + res += intr; + } else { - for (var k = 0, kk = intr.length; k < kk; k++) { - intr[k].segment1 = i; - intr[k].segment2 = j; - intr[k].bez1 = bez1; - intr[k].bez2 = bez2; - } + for (var k = 0, kk = intr.length; k < kk; k++) { + intr[k].segment1 = s1.segment; + intr[k].segment2 = s2.segment; + intr[k].bez1 = s1.bez; + intr[k].bez2 = s2.bez; - res = res.concat(intr); - } + res.push(intr[k]); } } } @@ -490,6 +460,50 @@ export default function findPathIntersections(path1, path2, justCount) { return res; } +/** + * Convert a curved path (only 'M' and 'C' commands) to a list of + * bezier segments, each annotated with its bounding box and the + * index of the originating path segment. + * + * @param {PathComponent[]} pathComponents + * + * @return {{ bez: number[], bbox: object, segment: number }[]} + */ +function pathToSegments(pathComponents) { + var segments = [], + x = 0, y = 0, xm = 0, ym = 0; + + for (var i = 0, ii = pathComponents.length; i < ii; i++) { + var pi = pathComponents[i], + bez; + + if (pi[0] == 'M') { + x = xm = pi[1]; + y = ym = pi[2]; + + continue; + } + + if (pi[0] == 'C') { + bez = [ x, y, pi[1], pi[2], pi[3], pi[4], pi[5], pi[6] ]; + x = pi[5]; + y = pi[6]; + } else { + bez = [ x, y, x, y, xm, ym, xm, ym ]; + x = xm; + y = ym; + } + + segments.push({ + bez: bez, + bbox: bezierBBox(bez), + segment: i + }); + } + + return segments; +} + /** * Test if path is a set of path components of the form * `[ ['M', 0, 0 ], ['L', 100, 100], ...]`. From d61d7efa609bd278fc999e5584c30df8d7c5d07f Mon Sep 17 00:00:00 2001 From: Nico Rehwaldt Date: Sun, 20 Sep 2026 20:27:22 +0200 Subject: [PATCH 2/3] perf: compute arc rotation directly instead of per-call caching The rotate helper inside arcToCurve was wrapped in a cacher whose cache was rebuilt on every arcToCurve invocation, caching at most a handful of lookups while paying string key joins on each call. Replace it with a plain closure over precomputed sin/cos and drop the now unused cacher machinery. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- intersect.js | 54 ++++++++++------------------------------------------ 1 file changed, 10 insertions(+), 44 deletions(-) diff --git a/intersect.js b/intersect.js index e608ca7..45c5879 100644 --- a/intersect.js +++ b/intersect.js @@ -25,39 +25,6 @@ var p2s = /,?([a-z]),?/gi, var isArray = Array.isArray || function(o) { return o instanceof Array; }; -function hasProperty(obj, property) { - return Object.prototype.hasOwnProperty.call(obj, property); -} - -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]); - } -} - -function cacher(f) { - - function newf() { - - var arg = Array.prototype.slice.call(arguments, 0), - args = arg.join('\u2400'), - cache = newf.cache = newf.cache || {}, - count = newf.count = newf.count || []; - - if (hasProperty(cache, args)) { - repush(count, args); - return cache[args]; - } - - count.length >= 1e3 && delete cache[count.shift()]; - count.push(args); - cache[args] = f(...arguments); - - return cache[args]; - } - return newf; -} - /** * Parse SVG path string and return an array of path components. * @@ -646,20 +613,19 @@ function arcToCurve(x1, y1, rx, ry, angle, large_arc_flag, sweep_flag, x2, y2, r rad = PI / 180 * (+angle || 0), res = [], xy, - rotate = cacher(function(x, y, rad) { - var X = x * math.cos(rad) - y * math.sin(rad), - Y = x * math.sin(rad) + y * math.cos(rad); - - return { x: X, y: Y }; - }); + cos = math.cos(rad), + sin = math.sin(rad), + rotate = function(x, y) { + return { x: x * cos - y * sin, y: x * sin + y * cos }; + }; if (!recursive) { - xy = rotate(x1, y1, -rad); + xy = rotate(x1, -y1); x1 = xy.x; - y1 = xy.y; - xy = rotate(x2, y2, -rad); + y1 = -xy.y; + xy = rotate(x2, -y2); x2 = xy.x; - y2 = xy.y; + y2 = -xy.y; var x = (x1 - x2) / 2, y = (y1 - y2) / 2; @@ -736,7 +702,7 @@ function arcToCurve(x1, y1, rx, ry, angle, large_arc_flag, sweep_flag, x2, y2, r var newres = []; for (var i = 0, ii = res.length; i < ii; i++) { - newres[i] = i % 2 ? rotate(res[i - 1], res[i], rad).y : rotate(res[i], res[i + 1], rad).x; + newres[i] = i % 2 ? rotate(res[i - 1], res[i]).y : rotate(res[i], res[i + 1]).x; } return newres; From b65e4fe65bad6cdaa429ae7870c01d9bc8273ab1 Mon Sep 17 00:00:00 2001 From: Nico Rehwaldt Date: Sun, 20 Sep 2026 20:28:44 +0200 Subject: [PATCH 3/3] test: verify intersections with many disjoint segment bounding boxes Guards the bounding box precheck that skips non-overlapping segment pairs: intersections must be found exactly where segments overlap, no more, no less. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/intersect.spec.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/intersect.spec.js b/test/intersect.spec.js index 5bb001e..5c5ea40 100644 --- a/test/intersect.spec.js +++ b/test/intersect.spec.js @@ -376,6 +376,25 @@ describe('path-intersection', function() { }); + test('many segments, disjoint bounding boxes', { + + // ensure segment pairs skipped via bounding box precheck + // do not yield (or hide) intersections + p0: 'M0,0 L100,0 L100,100 L0,100 Z M200,200 L300,200 L300,300 L200,300 Z', + p1: 'M50,-50 L50,150 L150,50 L-50,50 Z M400,400 L500,400 L500,500 L400,500 Z', + expectedIntersections: [ + { x: 50, y: -0, segment1: 1, segment2: 1 }, + { x: -0, y: -0, segment1: 1, segment2: 4 }, + { x: 100, y: 100, segment1: 2, segment2: 2 }, + { x: 100, y: 50, segment1: 2, segment2: 3 }, + { x: 50, y: 100, segment1: 3, segment2: 1 }, + { x: 100, y: 100, segment1: 3, segment2: 2 }, + { x: -0, y: 50, segment1: 4, segment2: 3 }, + { x: -0, y: 0, segment1: 4, segment2: 4 } + ] + }); + + test('two ellipses', { p0: 'M2.6146209161795992e-14,73 A427,427 -90,0,0 -7.843862748538798e-14,927 A427,427 -90,1,0 2.6146209161795992e-14,73', p1: 'M71.048,16.789855835444428 A439.5,439.5 0,0,1 928.6918008943089,15.63106872689174 A439.5,439.5 0,1,1 71.048,16.789855835444428',