├── .eslintrc
├── .github
└── workflows
│ └── CI.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── NOTICE
├── README.md
├── intersect.d.ts
├── intersect.js
├── karma.conf.cjs
├── package-lock.json
├── package.json
├── resources
└── examples.png
├── test
├── .eslintrc
├── intersect.spec.js
└── intersect.spec.ts
└── tsconfig.json
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "plugin:bpmn-io/browser",
3 | "rules": {
4 | "no-bitwise": 0
5 | }
6 | }
--------------------------------------------------------------------------------
/.github/workflows/CI.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: [ push, pull_request ]
3 | jobs:
4 | Build:
5 |
6 | strategy:
7 | matrix:
8 | os: [ ubuntu-latest ]
9 | node-version: [ 20 ]
10 |
11 | runs-on: ${{ matrix.os }}
12 |
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v3
16 | - name: Use Node.js
17 | uses: actions/setup-node@v3
18 | with:
19 | node-version: ${{ matrix.node-version }}
20 | cache: 'npm'
21 | - name: Install dependencies
22 | run: npm ci
23 | - name: Build
24 | run: npm run all
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .idea
3 | *.iml
4 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to [path-intersection](https://github.com/bpmn-io/path-intersection) are documented here. We use [semantic versioning](http://semver.org/) for releases.
4 |
5 | ## Unreleased
6 |
7 | ___Note:__ Yet to be released changes appear here._
8 |
9 | ## 3.1.0
10 |
11 | * `FIX`: correct type declaration ([#23](https://github.com/bpmn-io/path-intersection/pull/23))
12 | * `CHORE`: add `exports` field
13 |
14 | ## 3.0.0
15 |
16 | * `FEAT`: turn into module
17 |
18 | ### Breaking Changes
19 |
20 | * You must now configure a module transpiler such as Babel or Webpack in order to import from non-ES modules code.
21 |
22 | ## 2.2.1
23 |
24 | * `FIX`: correct parsing of paths with scientific notation ([#17](https://github.com/bpmn-io/path-intersection/issues/17), [#18](https://github.com/bpmn-io/path-intersection/issues/18), [#19](https://github.com/bpmn-io/path-intersection/issues/19))
25 |
26 | ## 2.2.0
27 |
28 | * `FEAT`: optimize intersection finding for vertical or horizontal lines ([#9](https://github.com/bpmn-io/path-intersection/issues/9))
29 |
30 | ## 2.1.0
31 |
32 | * `CHORE`: various code cleanups
33 |
34 | ## 2.0.2
35 |
36 | * `FIX`: safely clone things
37 |
38 | ## 2.0.1
39 |
40 | * `FIX`: handle path segments with length smaller than five pixel ([`f733e90f`](https://github.com/bpmn-io/path-intersection/commit/f733e90f5fd5251ca103f82d48cf84f5cf4d3ffc))
41 | * `FIX`: compensate rounding error ([`6433915d`](https://github.com/bpmn-io/path-intersection/commit/6433915d11d6ddab3942c240fe6adf090bc3ca06))
42 | * `CHORE`: slightly increase duplicate filtering accuracy ([`f37c0567`](https://github.com/bpmn-io/path-intersection/commit/f37c05672a9cfd413b032c4f9dd5a8e54a780541))
43 | * `CHORE`: various code cleanups
44 |
45 | ## 2.0.0
46 |
47 | * `CHORE`: remove intersection logic for non-standardized path descriptors ([`d6f07947`](https://github.com/bpmn-io/path-intersection/commit/d6f079474baf091914ee261efd98a88c4bf1990d))
48 |
49 | ## 1.1.1
50 |
51 | * `FIX`: correct TypeScript definitions to match export
52 |
53 | ## 1.1.0
54 |
55 | * `FEAT`: add TypeScript definitions
56 |
57 | ## ...
58 |
59 | Check `git log` for earlier history.
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017 camunda Services GmbH
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
--------------------------------------------------------------------------------
/NOTICE:
--------------------------------------------------------------------------------
1 | path-intersection is licensed under the MIT license, 2017 (see LICENSE file).
2 |
3 | It's implementation is derived from the intersection login used in
4 | Snap.svg. See license notice below:
5 |
6 |
7 | https://github.com/adobe-webplatform/Snap.svg
8 |
9 | Copyright 2013 Adobe Systems Incorporated
10 |
11 | Licensed under the Apache License, Version 2.0 (the "License");
12 | you may not use this file except in compliance with the License.
13 | You may obtain a copy of the License at
14 |
15 | http://www.apache.org/licenses/LICENSE-2.0
16 |
17 | Unless required by applicable law or agreed to in writing, software
18 | distributed under the License is distributed on an "AS IS" BASIS,
19 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20 | See the License for the specific language governing permissions and
21 | limitations under the License.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # path-intersection
2 |
3 | [](https://github.com/bpmn-io/path-intersection/actions?query=workflow%3ACI)
4 |
5 | Computes the intersection between two SVG paths.
6 |
7 |
8 | ## Examples
9 |
10 |
11 |
12 | Execute `npm run dev` and navigate to [`http://localhost:9876/debug.html`](http://localhost:9876/debug.html) to see more examples.
13 |
14 |
15 | ## Usage
16 |
17 | ```javascript
18 | import intersect from 'path-intersection';
19 |
20 | const path0 = 'M30,100L270,20';
21 | const path1 = 'M150,150m0,-18a18,18,0,1,1,0,36a18,18,0,1,1,0,-36z';
22 |
23 | const intersection = intersect(path0, path1);
24 | // [ { x: ..., y: ..., segment1: ..., segment2: ... }, ... ]
25 | ```
26 |
27 | Results are approximate, as we use [bezier clipping](https://math.stackexchange.com/questions/118937) to find intersections.
28 |
29 |
30 | ## Building the Project
31 |
32 | ```
33 | # install dependencies
34 | npm install
35 |
36 | # build and test the library
37 | npm run all
38 | ```
39 |
40 |
41 | ## Credits
42 |
43 | The intersection logic provided by this library is derived from [`path.js`](https://github.com/adobe-webplatform/Snap.svg/blob/master/src/path.js), a part of [Snap.svg](https://github.com/adobe-webplatform/Snap.svg).
44 |
45 |
46 | ## License
47 |
48 | Use under the terms of the [MIT license](http://opensource.org/licenses/MIT).
49 |
--------------------------------------------------------------------------------
/intersect.d.ts:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * Find or counts the intersections between two SVG paths.
4 | *
5 | * Returns a number in counting mode and a list of intersections otherwise.
6 | *
7 | * A single intersection entry contains the intersection coordinates (x, y)
8 | * as well as additional information regarding the intersecting segments
9 | * on each path (segment1, segment2) and the relative location of the
10 | * intersection on these segments (t1, t2).
11 | *
12 | * The path may be an SVG path string or a list of path components
13 | * such as `[ [ 'M', 0, 10 ], [ 'L', 20, 0 ] ]`.
14 | *
15 | * @example
16 | *
17 | * var intersections = findPathIntersections(
18 | * 'M0,0L100,100',
19 | * [ [ 'M', 0, 100 ], [ 'L', 100, 0 ] ]
20 | * );
21 | *
22 | * // intersections = [
23 | * // { x: 50, y: 50, segment1: 1, segment2: 1, t1: 0.5, t2: 0.5 }
24 | * // ];
25 | *
26 | * @param {String|Array} path1
27 | * @param {String|Array} path2
28 | * @param {Boolean} [justCount=false]
29 | *
30 | * @return {Array|Number}
31 | */
32 | declare function findPathIntersections(path1: Path, path2: Path, justCount: true): number;
33 | declare function findPathIntersections(path1: Path, path2: Path, justCount: false): Intersection[];
34 | declare function findPathIntersections(path1: Path, path2: Path): Intersection[];
35 | declare function findPathIntersections(path1: Path, path2: Path, justCount?: boolean): Intersection[] | number;
36 |
37 | export default findPathIntersections;
38 |
39 | /**
40 | * A string in the form of 'M150,150m0,-18a18,18,0,1,1,0,36a18,18,0,1,1,0,-36z'
41 | * or something like:
42 | * [
43 | * ['M', 1, 2],
44 | * ['m', 0, -2],
45 | * ['a', 1, 1, 0, 1, 1, 0, 2 * 1],
46 | * ['a', 1, 1, 0, 1, 1, 0, -2 * 1],
47 | * ['z']
48 | * ]
49 | */
50 | declare type Path = string | PathComponent[];
51 | declare type PathComponent = any[];
52 |
53 | declare interface Intersection {
54 | /**
55 | * Segment of first path.
56 | */
57 | segment1: number;
58 |
59 | /**
60 | * Segment of first path.
61 | */
62 | segment2: number;
63 |
64 | /**
65 | * The x coordinate.
66 | */
67 | x: number;
68 |
69 | /**
70 | * The y coordinate.
71 | */
72 | y: number;
73 |
74 | /**
75 | * Bezier curve for matching path segment 1.
76 | */
77 | bez1: number[];
78 |
79 | /**
80 | * Bezier curve for matching path segment 2.
81 | */
82 | bez2: number[];
83 |
84 | /**
85 | * Relative position of intersection on path segment1 (0.5 => in middle, 0.0 => at start, 1.0 => at end).
86 | */
87 | t1: number;
88 |
89 | /**
90 | * Relative position of intersection on path segment2 (0.5 => in middle, 0.0 => at start, 1.0 => at end).
91 | */
92 | t2: number;
93 | }
94 |
95 | export type { Intersection, Path, PathComponent };
96 |
--------------------------------------------------------------------------------
/intersect.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This file contains source code adapted from Snap.svg (licensed Apache-2.0).
3 | *
4 | * @see https://github.com/adobe-webplatform/Snap.svg/blob/master/src/path.js
5 | */
6 |
7 | /* eslint no-fallthrough: "off" */
8 |
9 | var p2s = /,?([a-z]),?/gi,
10 | toFloat = parseFloat,
11 | math = Math,
12 | PI = math.PI,
13 | mmin = math.min,
14 | mmax = math.max,
15 | pow = math.pow,
16 | abs = math.abs,
17 | pathCommand = /([a-z])[\s,]*((-?\d*\.?\d*(?:e[-+]?\d+)?[\s]*,?[\s]*)+)/ig,
18 | pathValues = /(-?\d*\.?\d*(?:e[-+]?\d+)?)[\s]*,?[\s]*/ig;
19 |
20 | var isArray = Array.isArray || function(o) { return o instanceof Array; };
21 |
22 | function hasProperty(obj, property) {
23 | return Object.prototype.hasOwnProperty.call(obj, property);
24 | }
25 |
26 | function clone(obj) {
27 |
28 | if (typeof obj == 'function' || Object(obj) !== obj) {
29 | return obj;
30 | }
31 |
32 | var res = new obj.constructor;
33 |
34 | for (var key in obj) {
35 | if (hasProperty(obj, key)) {
36 | res[key] = clone(obj[key]);
37 | }
38 | }
39 |
40 | return res;
41 | }
42 |
43 | function repush(array, item) {
44 | for (var i = 0, ii = array.length; i < ii; i++) if (array[i] === item) {
45 | return array.push(array.splice(i, 1)[0]);
46 | }
47 | }
48 |
49 | function cacher(f) {
50 |
51 | function newf() {
52 |
53 | var arg = Array.prototype.slice.call(arguments, 0),
54 | args = arg.join('\u2400'),
55 | cache = newf.cache = newf.cache || {},
56 | count = newf.count = newf.count || [];
57 |
58 | if (hasProperty(cache, args)) {
59 | repush(count, args);
60 | return cache[args];
61 | }
62 |
63 | count.length >= 1e3 && delete cache[count.shift()];
64 | count.push(args);
65 | cache[args] = f(...arguments);
66 |
67 | return cache[args];
68 | }
69 | return newf;
70 | }
71 |
72 | function parsePathString(pathString) {
73 |
74 | if (!pathString) {
75 | return null;
76 | }
77 |
78 | var pth = paths(pathString);
79 |
80 | if (pth.arr) {
81 | return clone(pth.arr);
82 | }
83 |
84 | var paramCounts = { a: 7, c: 6, h: 1, l: 2, m: 2, q: 4, s: 4, t: 2, v: 1, z: 0 },
85 | data = [];
86 |
87 | if (isArray(pathString) && isArray(pathString[0])) { // rough assumption
88 | data = clone(pathString);
89 | }
90 |
91 | if (!data.length) {
92 |
93 | String(pathString).replace(pathCommand, function(a, b, c) {
94 | var params = [],
95 | name = b.toLowerCase();
96 |
97 | c.replace(pathValues, function(a, b) {
98 | b && params.push(+b);
99 | });
100 |
101 | if (name == 'm' && params.length > 2) {
102 | data.push([ b, ...params.splice(0, 2) ]);
103 | name = 'l';
104 | b = b == 'm' ? 'l' : 'L';
105 | }
106 |
107 | while (params.length >= paramCounts[name]) {
108 | data.push([ b, ...params.splice(0, paramCounts[name]) ]);
109 | if (!paramCounts[name]) {
110 | break;
111 | }
112 | }
113 | });
114 | }
115 |
116 | data.toString = paths.toString;
117 | pth.arr = clone(data);
118 |
119 | return data;
120 | }
121 |
122 | function paths(ps) {
123 | var p = paths.ps = paths.ps || {};
124 |
125 | if (p[ps]) {
126 | p[ps].sleep = 100;
127 | } else {
128 | p[ps] = {
129 | sleep: 100
130 | };
131 | }
132 |
133 | setTimeout(function() {
134 | for (var key in p) {
135 | if (hasProperty(p, key) && key != ps) {
136 | p[key].sleep--;
137 | !p[key].sleep && delete p[key];
138 | }
139 | }
140 | });
141 |
142 | return p[ps];
143 | }
144 |
145 | function rectBBox(x, y, width, height) {
146 |
147 | if (arguments.length === 1) {
148 | y = x.y;
149 | width = x.width;
150 | height = x.height;
151 | x = x.x;
152 | }
153 |
154 | return {
155 | x: x,
156 | y: y,
157 | width: width,
158 | height: height,
159 | x2: x + width,
160 | y2: y + height
161 | };
162 | }
163 |
164 | function pathToString() {
165 | return this.join(',').replace(p2s, '$1');
166 | }
167 |
168 | function pathClone(pathArray) {
169 | var res = clone(pathArray);
170 | res.toString = pathToString;
171 | return res;
172 | }
173 |
174 | function findDotsAtSegment(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, t) {
175 | var t1 = 1 - t,
176 | t13 = pow(t1, 3),
177 | t12 = pow(t1, 2),
178 | t2 = t * t,
179 | t3 = t2 * t,
180 | x = t13 * p1x + t12 * 3 * t * c1x + t1 * 3 * t * t * c2x + t3 * p2x,
181 | y = t13 * p1y + t12 * 3 * t * c1y + t1 * 3 * t * t * c2y + t3 * p2y;
182 |
183 | return {
184 | x: fixError(x),
185 | y: fixError(y)
186 | };
187 | }
188 |
189 | function bezierBBox(points) {
190 |
191 | var bbox = curveBBox(...points);
192 |
193 | return rectBBox(
194 | bbox.x0,
195 | bbox.y0,
196 | bbox.x1 - bbox.x0,
197 | bbox.y1 - bbox.y0
198 | );
199 | }
200 |
201 | function isPointInsideBBox(bbox, x, y) {
202 | return x >= bbox.x &&
203 | x <= bbox.x + bbox.width &&
204 | y >= bbox.y &&
205 | y <= bbox.y + bbox.height;
206 | }
207 |
208 | function isBBoxIntersect(bbox1, bbox2) {
209 | bbox1 = rectBBox(bbox1);
210 | bbox2 = rectBBox(bbox2);
211 | return isPointInsideBBox(bbox2, bbox1.x, bbox1.y)
212 | || isPointInsideBBox(bbox2, bbox1.x2, bbox1.y)
213 | || isPointInsideBBox(bbox2, bbox1.x, bbox1.y2)
214 | || isPointInsideBBox(bbox2, bbox1.x2, bbox1.y2)
215 | || isPointInsideBBox(bbox1, bbox2.x, bbox2.y)
216 | || isPointInsideBBox(bbox1, bbox2.x2, bbox2.y)
217 | || isPointInsideBBox(bbox1, bbox2.x, bbox2.y2)
218 | || isPointInsideBBox(bbox1, bbox2.x2, bbox2.y2)
219 | || (bbox1.x < bbox2.x2 && bbox1.x > bbox2.x
220 | || bbox2.x < bbox1.x2 && bbox2.x > bbox1.x)
221 | && (bbox1.y < bbox2.y2 && bbox1.y > bbox2.y
222 | || bbox2.y < bbox1.y2 && bbox2.y > bbox1.y);
223 | }
224 |
225 | function base3(t, p1, p2, p3, p4) {
226 | var t1 = -3 * p1 + 9 * p2 - 9 * p3 + 3 * p4,
227 | t2 = t * t1 + 6 * p1 - 12 * p2 + 6 * p3;
228 | return t * t2 - 3 * p1 + 3 * p2;
229 | }
230 |
231 | function bezlen(x1, y1, x2, y2, x3, y3, x4, y4, z) {
232 |
233 | if (z == null) {
234 | z = 1;
235 | }
236 |
237 | z = z > 1 ? 1 : z < 0 ? 0 : z;
238 |
239 | var z2 = z / 2,
240 | n = 12,
241 | Tvalues = [ -.1252,.1252,-.3678,.3678,-.5873,.5873,-.7699,.7699,-.9041,.9041,-.9816,.9816 ],
242 | Cvalues = [ 0.2491,0.2491,0.2335,0.2335,0.2032,0.2032,0.1601,0.1601,0.1069,0.1069,0.0472,0.0472 ],
243 | sum = 0;
244 |
245 | for (var i = 0; i < n; i++) {
246 | var ct = z2 * Tvalues[i] + z2,
247 | xbase = base3(ct, x1, x2, x3, x4),
248 | ybase = base3(ct, y1, y2, y3, y4),
249 | comb = xbase * xbase + ybase * ybase;
250 |
251 | sum += Cvalues[i] * math.sqrt(comb);
252 | }
253 |
254 | return z2 * sum;
255 | }
256 |
257 |
258 | function intersectLines(x1, y1, x2, y2, x3, y3, x4, y4) {
259 |
260 | if (
261 | mmax(x1, x2) < mmin(x3, x4) ||
262 | mmin(x1, x2) > mmax(x3, x4) ||
263 | mmax(y1, y2) < mmin(y3, y4) ||
264 | mmin(y1, y2) > mmax(y3, y4)
265 | ) {
266 | return;
267 | }
268 |
269 | var nx = (x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4),
270 | ny = (x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4),
271 | denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
272 |
273 | if (!denominator) {
274 | return;
275 | }
276 |
277 | var px = fixError(nx / denominator),
278 | py = fixError(ny / denominator),
279 | px2 = +px.toFixed(2),
280 | py2 = +py.toFixed(2);
281 |
282 | if (
283 | px2 < +mmin(x1, x2).toFixed(2) ||
284 | px2 > +mmax(x1, x2).toFixed(2) ||
285 | px2 < +mmin(x3, x4).toFixed(2) ||
286 | px2 > +mmax(x3, x4).toFixed(2) ||
287 | py2 < +mmin(y1, y2).toFixed(2) ||
288 | py2 > +mmax(y1, y2).toFixed(2) ||
289 | py2 < +mmin(y3, y4).toFixed(2) ||
290 | py2 > +mmax(y3, y4).toFixed(2)
291 | ) {
292 | return;
293 | }
294 |
295 | return { x: px, y: py };
296 | }
297 |
298 | function fixError(number) {
299 | return Math.round(number * 100000000000) / 100000000000;
300 | }
301 |
302 | function findBezierIntersections(bez1, bez2, justCount) {
303 | var bbox1 = bezierBBox(bez1),
304 | bbox2 = bezierBBox(bez2);
305 |
306 | if (!isBBoxIntersect(bbox1, bbox2)) {
307 | return justCount ? 0 : [];
308 | }
309 |
310 | // As an optimization, lines will have only 1 segment
311 |
312 | var l1 = bezlen(...bez1),
313 | l2 = bezlen(...bez2),
314 | n1 = isLine(bez1) ? 1 : ~~(l1 / 5) || 1,
315 | n2 = isLine(bez2) ? 1 : ~~(l2 / 5) || 1,
316 | dots1 = [],
317 | dots2 = [],
318 | xy = {},
319 | res = justCount ? 0 : [];
320 |
321 | for (var i = 0; i < n1 + 1; i++) {
322 | var p = findDotsAtSegment(...bez1, i / n1);
323 | dots1.push({ x: p.x, y: p.y, t: i / n1 });
324 | }
325 |
326 | for (i = 0; i < n2 + 1; i++) {
327 | p = findDotsAtSegment(...bez2, i / n2);
328 | dots2.push({ x: p.x, y: p.y, t: i / n2 });
329 | }
330 |
331 | for (i = 0; i < n1; i++) {
332 |
333 | for (var j = 0; j < n2; j++) {
334 | var di = dots1[i],
335 | di1 = dots1[i + 1],
336 | dj = dots2[j],
337 | dj1 = dots2[j + 1],
338 | ci = abs(di1.x - di.x) < .01 ? 'y' : 'x',
339 | cj = abs(dj1.x - dj.x) < .01 ? 'y' : 'x',
340 | is = intersectLines(di.x, di.y, di1.x, di1.y, dj.x, dj.y, dj1.x, dj1.y),
341 | key;
342 |
343 | if (is) {
344 | key = is.x.toFixed(9) + '#' + is.y.toFixed(9);
345 |
346 | if (xy[key]) {
347 | continue;
348 | }
349 |
350 | xy[key] = true;
351 |
352 | var t1 = di.t + abs((is[ci] - di[ci]) / (di1[ci] - di[ci])) * (di1.t - di.t),
353 | t2 = dj.t + abs((is[cj] - dj[cj]) / (dj1[cj] - dj[cj])) * (dj1.t - dj.t);
354 |
355 | if (t1 >= 0 && t1 <= 1 && t2 >= 0 && t2 <= 1) {
356 |
357 | if (justCount) {
358 | res++;
359 | } else {
360 | res.push({
361 | x: is.x,
362 | y: is.y,
363 | t1: t1,
364 | t2: t2
365 | });
366 | }
367 | }
368 | }
369 | }
370 | }
371 |
372 | return res;
373 | }
374 |
375 |
376 | /**
377 | * Find or counts the intersections between two SVG paths.
378 | *
379 | * Returns a number in counting mode and a list of intersections otherwise.
380 | *
381 | * A single intersection entry contains the intersection coordinates (x, y)
382 | * as well as additional information regarding the intersecting segments
383 | * on each path (segment1, segment2) and the relative location of the
384 | * intersection on these segments (t1, t2).
385 | *
386 | * The path may be an SVG path string or a list of path components
387 | * such as `[ [ 'M', 0, 10 ], [ 'L', 20, 0 ] ]`.
388 | *
389 | * @example
390 | *
391 | * var intersections = findPathIntersections(
392 | * 'M0,0L100,100',
393 | * [ [ 'M', 0, 100 ], [ 'L', 100, 0 ] ]
394 | * );
395 | *
396 | * // intersections = [
397 | * // { x: 50, y: 50, segment1: 1, segment2: 1, t1: 0.5, t2: 0.5 }
398 | * // ]
399 | *
400 | * @param {String|Array} path1
401 | * @param {String|Array} path2
402 | * @param {Boolean} [justCount=false]
403 | *
404 | * @return {Array|Number}
405 | */
406 | export default function findPathIntersections(path1, path2, justCount) {
407 | path1 = pathToCurve(path1);
408 | path2 = pathToCurve(path2);
409 |
410 | var x1, y1, x2, y2, x1m, y1m, x2m, y2m, bez1, bez2,
411 | res = justCount ? 0 : [];
412 |
413 | for (var i = 0, ii = path1.length; i < ii; i++) {
414 | var pi = path1[i];
415 |
416 | if (pi[0] == 'M') {
417 | x1 = x1m = pi[1];
418 | y1 = y1m = pi[2];
419 | } else {
420 |
421 | if (pi[0] == 'C') {
422 | bez1 = [ x1, y1, ...pi.slice(1) ];
423 | x1 = bez1[6];
424 | y1 = bez1[7];
425 | } else {
426 | bez1 = [ x1, y1, x1, y1, x1m, y1m, x1m, y1m ];
427 | x1 = x1m;
428 | y1 = y1m;
429 | }
430 |
431 | for (var j = 0, jj = path2.length; j < jj; j++) {
432 | var pj = path2[j];
433 |
434 | if (pj[0] == 'M') {
435 | x2 = x2m = pj[1];
436 | y2 = y2m = pj[2];
437 | } else {
438 |
439 | if (pj[0] == 'C') {
440 | bez2 = [ x2, y2, ...pj.slice(1) ];
441 | x2 = bez2[6];
442 | y2 = bez2[7];
443 | } else {
444 | bez2 = [ x2, y2, x2, y2, x2m, y2m, x2m, y2m ];
445 | x2 = x2m;
446 | y2 = y2m;
447 | }
448 |
449 | var intr = findBezierIntersections(bez1, bez2, justCount);
450 |
451 | if (justCount) {
452 | res += intr;
453 | } else {
454 |
455 | for (var k = 0, kk = intr.length; k < kk; k++) {
456 | intr[k].segment1 = i;
457 | intr[k].segment2 = j;
458 | intr[k].bez1 = bez1;
459 | intr[k].bez2 = bez2;
460 | }
461 |
462 | res = res.concat(intr);
463 | }
464 | }
465 | }
466 | }
467 | }
468 |
469 | return res;
470 | }
471 |
472 |
473 | function pathToAbsolute(pathArray) {
474 | var pth = paths(pathArray);
475 |
476 | if (pth.abs) {
477 | return pathClone(pth.abs);
478 | }
479 |
480 | if (!isArray(pathArray) || !isArray(pathArray && pathArray[0])) { // rough assumption
481 | pathArray = parsePathString(pathArray);
482 | }
483 |
484 | if (!pathArray || !pathArray.length) {
485 | return [ [ 'M', 0, 0 ] ];
486 | }
487 |
488 | var res = [],
489 | x = 0,
490 | y = 0,
491 | mx = 0,
492 | my = 0,
493 | start = 0,
494 | pa0;
495 |
496 | if (pathArray[0][0] == 'M') {
497 | x = +pathArray[0][1];
498 | y = +pathArray[0][2];
499 | mx = x;
500 | my = y;
501 | start++;
502 | res[0] = [ 'M', x, y ];
503 | }
504 |
505 | for (var r, pa, i = start, ii = pathArray.length; i < ii; i++) {
506 | res.push(r = []);
507 | pa = pathArray[i];
508 | pa0 = pa[0];
509 |
510 | if (pa0 != pa0.toUpperCase()) {
511 | r[0] = pa0.toUpperCase();
512 |
513 | switch (r[0]) {
514 | case 'A':
515 | r[1] = pa[1];
516 | r[2] = pa[2];
517 | r[3] = pa[3];
518 | r[4] = pa[4];
519 | r[5] = pa[5];
520 | r[6] = +pa[6] + x;
521 | r[7] = +pa[7] + y;
522 | break;
523 | case 'V':
524 | r[1] = +pa[1] + y;
525 | break;
526 | case 'H':
527 | r[1] = +pa[1] + x;
528 | break;
529 | case 'M':
530 | mx = +pa[1] + x;
531 | my = +pa[2] + y;
532 | default:
533 | for (var j = 1, jj = pa.length; j < jj; j++) {
534 | r[j] = +pa[j] + ((j % 2) ? x : y);
535 | }
536 | }
537 | } else {
538 | for (var k = 0, kk = pa.length; k < kk; k++) {
539 | r[k] = pa[k];
540 | }
541 | }
542 | pa0 = pa0.toUpperCase();
543 |
544 | switch (r[0]) {
545 | case 'Z':
546 | x = +mx;
547 | y = +my;
548 | break;
549 | case 'H':
550 | x = r[1];
551 | break;
552 | case 'V':
553 | y = r[1];
554 | break;
555 | case 'M':
556 | mx = r[r.length - 2];
557 | my = r[r.length - 1];
558 | default:
559 | x = r[r.length - 2];
560 | y = r[r.length - 1];
561 | }
562 | }
563 |
564 | res.toString = pathToString;
565 | pth.abs = pathClone(res);
566 |
567 | return res;
568 | }
569 |
570 | function isLine(bez) {
571 | return (
572 | bez[0] === bez[2] &&
573 | bez[1] === bez[3] &&
574 | bez[4] === bez[6] &&
575 | bez[5] === bez[7]
576 | );
577 | }
578 |
579 | function lineToCurve(x1, y1, x2, y2) {
580 | return [
581 | x1, y1, x2,
582 | y2, x2, y2
583 | ];
584 | }
585 |
586 | function qubicToCurve(x1, y1, ax, ay, x2, y2) {
587 | var _13 = 1 / 3,
588 | _23 = 2 / 3;
589 |
590 | return [
591 | _13 * x1 + _23 * ax,
592 | _13 * y1 + _23 * ay,
593 | _13 * x2 + _23 * ax,
594 | _13 * y2 + _23 * ay,
595 | x2,
596 | y2
597 | ];
598 | }
599 |
600 | function arcToCurve(x1, y1, rx, ry, angle, large_arc_flag, sweep_flag, x2, y2, recursive) {
601 |
602 | // for more information of where this math came from visit:
603 | // http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes
604 | var _120 = PI * 120 / 180,
605 | rad = PI / 180 * (+angle || 0),
606 | res = [],
607 | xy,
608 | rotate = cacher(function(x, y, rad) {
609 | var X = x * math.cos(rad) - y * math.sin(rad),
610 | Y = x * math.sin(rad) + y * math.cos(rad);
611 |
612 | return { x: X, y: Y };
613 | });
614 |
615 | if (!recursive) {
616 | xy = rotate(x1, y1, -rad);
617 | x1 = xy.x;
618 | y1 = xy.y;
619 | xy = rotate(x2, y2, -rad);
620 | x2 = xy.x;
621 | y2 = xy.y;
622 |
623 | var x = (x1 - x2) / 2,
624 | y = (y1 - y2) / 2;
625 |
626 | var h = (x * x) / (rx * rx) + (y * y) / (ry * ry);
627 |
628 | if (h > 1) {
629 | h = math.sqrt(h);
630 | rx = h * rx;
631 | ry = h * ry;
632 | }
633 |
634 | var rx2 = rx * rx,
635 | ry2 = ry * ry,
636 | k = (large_arc_flag == sweep_flag ? -1 : 1) *
637 | math.sqrt(abs((rx2 * ry2 - rx2 * y * y - ry2 * x * x) / (rx2 * y * y + ry2 * x * x))),
638 | cx = k * rx * y / ry + (x1 + x2) / 2,
639 | cy = k * -ry * x / rx + (y1 + y2) / 2,
640 | f1 = math.asin(((y1 - cy) / ry).toFixed(9)),
641 | f2 = math.asin(((y2 - cy) / ry).toFixed(9));
642 |
643 | f1 = x1 < cx ? PI - f1 : f1;
644 | f2 = x2 < cx ? PI - f2 : f2;
645 | f1 < 0 && (f1 = PI * 2 + f1);
646 | f2 < 0 && (f2 = PI * 2 + f2);
647 |
648 | if (sweep_flag && f1 > f2) {
649 | f1 = f1 - PI * 2;
650 | }
651 | if (!sweep_flag && f2 > f1) {
652 | f2 = f2 - PI * 2;
653 | }
654 | } else {
655 | f1 = recursive[0];
656 | f2 = recursive[1];
657 | cx = recursive[2];
658 | cy = recursive[3];
659 | }
660 |
661 | var df = f2 - f1;
662 |
663 | if (abs(df) > _120) {
664 | var f2old = f2,
665 | x2old = x2,
666 | y2old = y2;
667 |
668 | f2 = f1 + _120 * (sweep_flag && f2 > f1 ? 1 : -1);
669 | x2 = cx + rx * math.cos(f2);
670 | y2 = cy + ry * math.sin(f2);
671 | res = arcToCurve(x2, y2, rx, ry, angle, 0, sweep_flag, x2old, y2old, [ f2, f2old, cx, cy ]);
672 | }
673 |
674 | df = f2 - f1;
675 |
676 | var c1 = math.cos(f1),
677 | s1 = math.sin(f1),
678 | c2 = math.cos(f2),
679 | s2 = math.sin(f2),
680 | t = math.tan(df / 4),
681 | hx = 4 / 3 * rx * t,
682 | hy = 4 / 3 * ry * t,
683 | m1 = [ x1, y1 ],
684 | m2 = [ x1 + hx * s1, y1 - hy * c1 ],
685 | m3 = [ x2 + hx * s2, y2 - hy * c2 ],
686 | m4 = [ x2, y2 ];
687 |
688 | m2[0] = 2 * m1[0] - m2[0];
689 | m2[1] = 2 * m1[1] - m2[1];
690 |
691 | if (recursive) {
692 | return [ m2, m3, m4 ].concat(res);
693 | } else {
694 | res = [ m2, m3, m4 ].concat(res).join().split(',');
695 | var newres = [];
696 |
697 | for (var i = 0, ii = res.length; i < ii; i++) {
698 | newres[i] = i % 2 ? rotate(res[i - 1], res[i], rad).y : rotate(res[i], res[i + 1], rad).x;
699 | }
700 |
701 | return newres;
702 | }
703 | }
704 |
705 | // Returns bounding box of cubic bezier curve.
706 | // Source: http://blog.hackers-cafe.net/2009/06/how-to-calculate-bezier-curves-bounding.html
707 | // Original version: NISHIO Hirokazu
708 | // Modifications: https://github.com/timo22345
709 | function curveBBox(x0, y0, x1, y1, x2, y2, x3, y3) {
710 | var tvalues = [],
711 | bounds = [ [], [] ],
712 | a, b, c, t, t1, t2, b2ac, sqrtb2ac;
713 |
714 | for (var i = 0; i < 2; ++i) {
715 |
716 | if (i == 0) {
717 | b = 6 * x0 - 12 * x1 + 6 * x2;
718 | a = -3 * x0 + 9 * x1 - 9 * x2 + 3 * x3;
719 | c = 3 * x1 - 3 * x0;
720 | } else {
721 | b = 6 * y0 - 12 * y1 + 6 * y2;
722 | a = -3 * y0 + 9 * y1 - 9 * y2 + 3 * y3;
723 | c = 3 * y1 - 3 * y0;
724 | }
725 |
726 | if (abs(a) < 1e-12) {
727 |
728 | if (abs(b) < 1e-12) {
729 | continue;
730 | }
731 |
732 | t = -c / b;
733 |
734 | if (0 < t && t < 1) {
735 | tvalues.push(t);
736 | }
737 |
738 | continue;
739 | }
740 |
741 | b2ac = b * b - 4 * c * a;
742 | sqrtb2ac = math.sqrt(b2ac);
743 |
744 | if (b2ac < 0) {
745 | continue;
746 | }
747 |
748 | t1 = (-b + sqrtb2ac) / (2 * a);
749 |
750 | if (0 < t1 && t1 < 1) {
751 | tvalues.push(t1);
752 | }
753 |
754 | t2 = (-b - sqrtb2ac) / (2 * a);
755 |
756 | if (0 < t2 && t2 < 1) {
757 | tvalues.push(t2);
758 | }
759 | }
760 |
761 | var j = tvalues.length,
762 | jlen = j,
763 | mt;
764 |
765 | while (j--) {
766 | t = tvalues[j];
767 | mt = 1 - t;
768 | bounds[0][j] = (mt * mt * mt * x0) + (3 * mt * mt * t * x1) + (3 * mt * t * t * x2) + (t * t * t * x3);
769 | bounds[1][j] = (mt * mt * mt * y0) + (3 * mt * mt * t * y1) + (3 * mt * t * t * y2) + (t * t * t * y3);
770 | }
771 |
772 | bounds[0][jlen] = x0;
773 | bounds[1][jlen] = y0;
774 | bounds[0][jlen + 1] = x3;
775 | bounds[1][jlen + 1] = y3;
776 | bounds[0].length = bounds[1].length = jlen + 2;
777 |
778 | return {
779 | x0: mmin(...bounds[0]),
780 | y0: mmin(...bounds[1]),
781 | x1: mmax(...bounds[0]),
782 | y1: mmax(...bounds[1])
783 | };
784 | }
785 |
786 | function pathToCurve(path) {
787 |
788 | var pth = paths(path);
789 |
790 | // return cached curve, if existing
791 | if (pth.curve) {
792 | return pathClone(pth.curve);
793 | }
794 |
795 | var curvedPath = pathToAbsolute(path),
796 | attrs = { x: 0, y: 0, bx: 0, by: 0, X: 0, Y: 0, qx: null, qy: null },
797 | processPath = function(path, d, pathCommand) {
798 | var nx, ny;
799 |
800 | if (!path) {
801 | return [ 'C', d.x, d.y, d.x, d.y, d.x, d.y ];
802 | }
803 |
804 | !(path[0] in { T: 1, Q: 1 }) && (d.qx = d.qy = null);
805 |
806 | switch (path[0]) {
807 | case 'M':
808 | d.X = path[1];
809 | d.Y = path[2];
810 | break;
811 | case 'A':
812 | path = [ 'C', ...arcToCurve(d.x, d.y, ...path.slice(1)) ];
813 | break;
814 | case 'S':
815 | if (pathCommand == 'C' || pathCommand == 'S') {
816 |
817 | // In 'S' case we have to take into account, if the previous command is C/S.
818 | nx = d.x * 2 - d.bx;
819 |
820 | // And reflect the previous
821 | ny = d.y * 2 - d.by;
822 |
823 | // command's control point relative to the current point.
824 | }
825 | else {
826 |
827 | // or some else or nothing
828 | nx = d.x;
829 | ny = d.y;
830 | }
831 | path = [ 'C', nx, ny, ...path.slice(1) ];
832 | break;
833 | case 'T':
834 | if (pathCommand == 'Q' || pathCommand == 'T') {
835 |
836 | // In 'T' case we have to take into account, if the previous command is Q/T.
837 | d.qx = d.x * 2 - d.qx;
838 |
839 | // And make a reflection similar
840 | d.qy = d.y * 2 - d.qy;
841 |
842 | // to case 'S'.
843 | }
844 | else {
845 |
846 | // or something else or nothing
847 | d.qx = d.x;
848 | d.qy = d.y;
849 | }
850 | path = [ 'C', ...qubicToCurve(d.x, d.y, d.qx, d.qy, path[1], path[2]) ];
851 | break;
852 | case 'Q':
853 | d.qx = path[1];
854 | d.qy = path[2];
855 | path = [ 'C', ...qubicToCurve(d.x, d.y, path[1], path[2], path[3], path[4]) ];
856 | break;
857 | case 'L':
858 | path = [ 'C', ...lineToCurve(d.x, d.y, path[1], path[2]) ];
859 | break;
860 | case 'H':
861 | path = [ 'C', ...lineToCurve(d.x, d.y, path[1], d.y) ];
862 | break;
863 | case 'V':
864 | path = [ 'C', ...lineToCurve(d.x, d.y, d.x, path[1]) ];
865 | break;
866 | case 'Z':
867 | path = [ 'C', ...lineToCurve(d.x, d.y, d.X, d.Y) ];
868 | break;
869 | }
870 |
871 | return path;
872 | },
873 |
874 | fixArc = function(pp, i) {
875 |
876 | if (pp[i].length > 7) {
877 | pp[i].shift();
878 | var pi = pp[i];
879 |
880 | while (pi.length) {
881 | pathCommands[i] = 'A'; // if created multiple C:s, their original seg is saved
882 | pp.splice(i++, 0, [ 'C', ...pi.splice(0, 6) ]);
883 | }
884 |
885 | pp.splice(i, 1);
886 | ii = curvedPath.length;
887 | }
888 | },
889 |
890 | pathCommands = [], // path commands of original path p
891 | pfirst = '', // temporary holder for original path command
892 | pathCommand = ''; // holder for previous path command of original path
893 |
894 | for (var i = 0, ii = curvedPath.length; i < ii; i++) {
895 | curvedPath[i] && (pfirst = curvedPath[i][0]); // save current path command
896 |
897 | if (pfirst != 'C') // C is not saved yet, because it may be result of conversion
898 | {
899 | pathCommands[i] = pfirst; // Save current path command
900 | i && (pathCommand = pathCommands[i - 1]); // Get previous path command pathCommand
901 | }
902 | curvedPath[i] = processPath(curvedPath[i], attrs, pathCommand); // Previous path command is inputted to processPath
903 |
904 | if (pathCommands[i] != 'A' && pfirst == 'C') pathCommands[i] = 'C'; // A is the only command
905 | // which may produce multiple C:s
906 | // so we have to make sure that C is also C in original path
907 |
908 | fixArc(curvedPath, i); // fixArc adds also the right amount of A:s to pathCommands
909 |
910 | var seg = curvedPath[i],
911 | seglen = seg.length;
912 |
913 | attrs.x = seg[seglen - 2];
914 | attrs.y = seg[seglen - 1];
915 | attrs.bx = toFloat(seg[seglen - 4]) || attrs.x;
916 | attrs.by = toFloat(seg[seglen - 3]) || attrs.y;
917 | }
918 |
919 | // cache curve
920 | pth.curve = pathClone(curvedPath);
921 |
922 | return curvedPath;
923 | }
--------------------------------------------------------------------------------
/karma.conf.cjs:
--------------------------------------------------------------------------------
1 | /** eslint-env node */
2 |
3 | // configures browsers to run test against
4 | // any of [ 'ChromeHeadless', 'Chrome', 'Firefox' ]
5 | const browsers = (process.env.TEST_BROWSERS || 'ChromeHeadless').split(',');
6 |
7 | // use puppeteer provided Chrome for testing
8 | process.env.CHROME_BIN = require('puppeteer').executablePath();
9 |
10 |
11 | module.exports = function(karma) {
12 | karma.set({
13 |
14 | frameworks: [
15 | 'mocha',
16 | 'sinon-chai',
17 | 'webpack'
18 | ],
19 |
20 | files: [
21 | 'test/**/*.spec.js'
22 | ],
23 |
24 | preprocessors: {
25 | 'test/intersect.spec.js': [ 'webpack' ]
26 | },
27 |
28 | browsers: browsers,
29 |
30 | autoWatch: false,
31 | singleRun: true,
32 |
33 | webpack: {
34 | mode: 'development',
35 | devtool: 'eval-source-map'
36 | }
37 | });
38 | };
39 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "path-intersection",
3 | "version": "3.1.0",
4 | "description": "Computes the intersection between two SVG paths",
5 | "main": "intersect.js",
6 | "types": "intersect.d.ts",
7 | "type": "module",
8 | "exports": {
9 | ".": {
10 | "import": "./intersect.js",
11 | "types": "./intersect.d.ts"
12 | },
13 | "./package.json": "./package.json"
14 | },
15 | "scripts": {
16 | "all": "run-s lint check-types test",
17 | "lint": "eslint .",
18 | "dev": "npm test -- --auto-watch --no-single-run",
19 | "test": "karma start karma.conf.cjs",
20 | "check-types": "tsc --noEmit"
21 | },
22 | "engines": {
23 | "node": ">= 14.20"
24 | },
25 | "repository": {
26 | "type": "git",
27 | "url": "https://github.com/bpmn-io/path-intersection"
28 | },
29 | "keywords": [
30 | "svg",
31 | "path",
32 | "path intersection"
33 | ],
34 | "author": {
35 | "name": "Nico Rehwaldt",
36 | "url": "https://github.com/nikku"
37 | },
38 | "license": "MIT",
39 | "devDependencies": {
40 | "@types/chai": "^4.3.12",
41 | "@types/karma-chai": "^0.1.6",
42 | "@types/mocha": "^10.0.6",
43 | "chai": "^4.4.1",
44 | "domify": "^2.0.0",
45 | "eslint": "^8.55.0",
46 | "eslint-plugin-bpmn-io": "^1.0.0",
47 | "karma": "^6.4.3",
48 | "karma-chrome-launcher": "^3.2.0",
49 | "karma-firefox-launcher": "^2.1.3",
50 | "karma-mocha": "^2.0.1",
51 | "karma-sinon-chai": "^2.0.2",
52 | "karma-webpack": "^5.0.1",
53 | "mocha": "^10.3.0",
54 | "npm-run-all": "^4.1.2",
55 | "puppeteer": "^22.4.1",
56 | "sinon": "^17.0.1",
57 | "sinon-chai": "^3.7.0",
58 | "typescript": "^5.4.2",
59 | "webpack": "^5.90.3"
60 | },
61 | "files": [
62 | "intersect.js",
63 | "intersect.d.ts",
64 | "NOTICE"
65 | ]
66 | }
67 |
--------------------------------------------------------------------------------
/resources/examples.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bpmn-io/path-intersection/869b184dc0be24b139622bc7e19e369939ce6740/resources/examples.png
--------------------------------------------------------------------------------
/test/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "plugin:bpmn-io/mocha",
3 | "env": {
4 | "browser": true
5 | }
6 | }
--------------------------------------------------------------------------------
/test/intersect.spec.js:
--------------------------------------------------------------------------------
1 | import intersect from 'path-intersection';
2 |
3 | import domify from 'domify';
4 |
5 |
6 | describe('path-intersection', function() {
7 |
8 | describe('api', function() {
9 |
10 | var p1 = [ [ 'M', 0, 0 ], [ 'L', 100, 100 ] ];
11 | var p2 = 'M0,100L100,0';
12 |
13 |
14 | it('should support SVG path and component args', function() {
15 |
16 | // when
17 | var intersections = intersect(p1, p2);
18 |
19 | // then
20 | expect(intersections).to.have.length(1);
21 | });
22 |
23 |
24 | it('should expose intersection', function() {
25 |
26 | // when
27 | var intersection = intersect(p1, p2)[0];
28 |
29 | // then
30 | expect(intersection.x).to.eql(50);
31 | expect(intersection.y).to.eql(50);
32 | expect(intersection.segment1).to.eql(1);
33 | expect(intersection.segment2).to.eql(1);
34 | expect(intersection.t1).to.eql(0.5);
35 | expect(intersection.t2).to.eql(0.5);
36 | expect(intersection.bez1).to.exist;
37 | expect(intersection.bez2).to.exist;
38 | });
39 |
40 | });
41 |
42 |
43 | describe('specs', function() {
44 |
45 | test('line with rounded rectangle (edge)', {
46 | p0: 'M80,140L100,140',
47 | p1: (
48 | 'M100,100l80,0' +
49 | 'a10,10,0,0,1,10,10l0,60' +
50 | 'a10,10,0,0,1,-10,10l-80,0' +
51 | 'a10,10,0,0,1,-10,-10l0,-60' +
52 | 'a10,10,0,0,1,10,-10z'
53 | ),
54 | expectedIntersections: [
55 | {
56 | x: 90,
57 | y: 140,
58 | segment1: 1,
59 | segment2: 7
60 | }
61 | ]
62 | });
63 |
64 |
65 | test('line with rounded rectangle corner (horizontal)', {
66 | p0: 'M80,105L100,105',
67 | p1: (
68 | 'M100,100l80,0' +
69 | 'a10,10,0,0,1,10,10l0,60' +
70 | 'a10,10,0,0,1,-10,10l-80,0' +
71 | 'a10,10,0,0,1,-10,-10l0,-60' +
72 | 'a10,10,0,0,1,10,-10z'
73 | ),
74 | expectedIntersections: [
75 | { x: 91, y: 105, segment1: 1, segment2: 8 }
76 | ]
77 | });
78 |
79 |
80 | test('line with rounded rectangle corner (vertical)', {
81 | p0: 'M70,50L100,120',
82 | p1: (
83 | 'M100,100l80,0' +
84 | 'a10,10,0,0,1,10,10l0,60' +
85 | 'a10,10,0,0,1,-10,10l-80,0' +
86 | 'a10,10,0,0,1,-10,-10l0,-60' +
87 | 'a10,10,0,0,1,10,-10z'
88 | ),
89 | expectedIntersections: [
90 | { x: 93, y: 103, segment1: 1, segment2: 8 }
91 | ]
92 | });
93 |
94 |
95 | test('line with rounded rectangle (cut corner)', {
96 | p0: 'M123,50L243,150',
97 | p1: (
98 | 'M100,100l80,0' +
99 | 'a10,10,0,0,1,10,10l0,60' +
100 | 'a10,10,0,0,1,-10,10l-80,0' +
101 | 'a10,10,0,0,1,-10,-10l0,-60' +
102 | 'a10,10,0,0,1,10,-10z'
103 | ),
104 | expectedIntersections: [
105 | { x: 184, y: 101, segment1: 1, segment2: 2 },
106 | { x: 187, y: 103, segment1: 1, segment2: 2 }
107 | ]
108 | });
109 |
110 |
111 | test('line with circle', {
112 | p0: 'M150,150m0,-18a18,18,0,1,1,0,36a18,18,0,1,1,0,-36z',
113 | p1: 'M100,100L150,150',
114 | expectedIntersections: [
115 | { x: 137, y: 137, segment1: 5, segment2: 1 }
116 | ]
117 | });
118 |
119 |
120 | test('line with circle (top)', {
121 | p0: 'M150,150m0,-18a18,18,0,1,1,0,36a18,18,0,1,1,0,-36z',
122 | p1: 'M150,100L150,150',
123 | expectedIntersections: [
124 | { x: 150, y: 132, segment1: 2, segment2: 1 },
125 | { x: 150, y: 132, segment1: 5, segment2: 1 }
126 | ]
127 | });
128 |
129 |
130 | test('line with circle (bottom)', {
131 | p0: 'M150,150m0,-18a18,18,0,1,1,0,36a18,18,0,1,1,0,-36z',
132 | p1: 'M150,150L150,200',
133 | expectedIntersections: [
134 | { x: 150, y: 168, segment1: 3, segment2: 1 },
135 | { x: 150, y: 168, segment1: 4, segment2: 1 }
136 | ]
137 | });
138 |
139 |
140 | test('line with circle (left)', {
141 | p0: 'M150,150m0,-18a18,18,0,1,1,0,36a18,18,0,1,1,0,-36z',
142 | p1: 'M100,150L150,150',
143 | expectedIntersections: [
144 | { x: 132, y: 150, segment1: 4, segment2: 1 }
145 | ]
146 | });
147 |
148 |
149 | test('line with circle (right)', {
150 | p0: 'M150,150m0,-18a18,18,0,1,1,0,36a18,18,0,1,1,0,-36z',
151 | p1: 'M150,150L200,150',
152 | expectedIntersections: [
153 | { x: 168, y: 150, segment1: 2, segment2: 1 }
154 | ]
155 | });
156 |
157 |
158 | test('line with diamond', {
159 | p0: 'M413,172l25,25l-25,25l-25,-25z',
160 | p1: 'M413,197L413,274L555,274',
161 | expectedIntersections: [
162 | { x: 413, y: 222, segment1: 2, segment2: 1 },
163 | { x: 413, y: 222, segment1: 3, segment2: 1 }
164 | ]
165 | });
166 |
167 |
168 | test('cut-through line with diamond', {
169 | p0: 'M413,172l25,25l-25,25l-25,-25z',
170 | p1: 'M413,97L413,274',
171 | expectedIntersections: [
172 | { x: 413, y: 172, segment1: 1, segment2: 1 },
173 | { x: 413, y: 222, segment1: 2, segment2: 1 },
174 | { x: 413, y: 222, segment1: 3, segment2: 1 },
175 | { x: 413, y: 172, segment1: 4, segment2: 1 }
176 | ]
177 | });
178 |
179 |
180 | test('line end on line', {
181 | p0: 'M170,150l80,0a10,10,0,0,1,10,10l0,60a10,10,0,0,1,-10,10l-80,0a10,10,0,0,1,-10,-10l0,-60a10,10,0,0,1,10,-10z',
182 | p1: 'M140,190L160,190',
183 | expectedIntersections: [
184 | { x: 160, y: 190, segment1: 7, segment2: 1 }
185 | ]
186 | });
187 |
188 |
189 | test('two lines, close proximity', {
190 | p0: 'M10,10 h8 v-5 h-5 v3',
191 | p1: 'M15,14 v-7 h6',
192 | expectedIntersections: [
193 | { x: 15, y: 10, segment1: 1, segment2: 1 },
194 | { x: 18, y: 7, segment1: 2, segment2: 2 }
195 | ]
196 | });
197 |
198 |
199 | test('two short lines, shared origin', {
200 | p0: 'M0,0 h8 v-5 h-5 v3',
201 | p1: 'M0,0 v-7 h6',
202 | expectedIntersections: [
203 | { x: -0, y: -0, segment1: 1, segment2: 1 }
204 | ]
205 | });
206 |
207 |
208 | test('two segments on same line, starting at same position', {
209 | p0: 'M0,0 h50',
210 | p1: 'M0,0 h-50',
211 | expectedIntersections: []
212 | });
213 |
214 |
215 | test('two segments on same line, ending at same position', {
216 | p0: 'M50,0 h-25',
217 | p1: 'M0,0 h25',
218 | expectedIntersections: []
219 | });
220 |
221 |
222 | test('two segments on same line, overlapping', {
223 | p0: 'M0,0 h30',
224 | p1: 'M0,0 h50',
225 | expectedIntersections: []
226 | });
227 |
228 |
229 | test('two diagonal lines', {
230 | p0: 'M0,0 L100,100',
231 | p1: 'M100,0 L0,100',
232 | expectedIntersections: [
233 | { x: 50, y: 50, segment1: 1, segment2: 1 }
234 | ]
235 | });
236 |
237 |
238 | test('points with scientific notation', {
239 | p0: 'M1.12345e-15,1.12345e-15 L100,100',
240 | p1: 'M100,0 L0,100',
241 | expectedIntersections: [
242 | { x: 50, y: 50, segment1: 1, segment2: 1 }
243 | ]
244 | });
245 |
246 |
247 | test('two ellipses', {
248 | p0: 'M2.6146209161795992e-14,73 A427,427 -90,0,0 -7.843862748538798e-14,927 A427,427 -90,1,0 2.6146209161795992e-14,73',
249 | 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',
250 | expectedIntersections: [
251 | { x: 425, y: 546, segment1: 3, segment2: 3 },
252 | { x: 62, y: 78, segment1: 4, segment2: 4 }
253 | ]
254 | });
255 |
256 | });
257 |
258 |
259 | describe('visual tests', function() {
260 |
261 | testScenario(
262 | 'M293,228L441,227 M253,188l80,0a10,10,0,0,1,10,10l0,60a10,10,0,0,1,-10,10l-80,0a10,10,0,0,1,-10,-10l0,-60a10,10,0,0,1,10,-10z M441,227m0,-18a18,18,0,1,1,0,36a18,18,0,1,1,0,-36z'
263 | );
264 |
265 |
266 | testScenario(
267 | 'M154,143L246,238 M129,118l50,0l0,50l-50,0z M231,195l80,0a10,10,0,0,1,10,10l0,60a10,10,0,0,1,-10,10l-80,0a10,10,0,0,1,-10,-10l0,-60a10,10,0,0,1,10,-10z'
268 | );
269 |
270 |
271 | testScenario(
272 | 'M271,215L380,203L380,136L441,136 M240,179l80,0a10,10,0,0,1,10,10l0,60a10,10,0,0,1,-10,10l-80,0a10,10,0,0,1,-10,-10l0,-60a10,10,0,0,1,10,-10z M441,136m0,-18a18,18,0,1,1,0,36a18,18,0,1,1,0,-36z'
273 | );
274 |
275 |
276 | testScenario(
277 | 'M402,354L402,159L274,118 M402,329l25,25l-25,25l-25,-25z M248,97l80,0a10,10,0,0,1,10,10l0,60a10,10,0,0,1,-10,10l-80,0a10,10,0,0,1,-10,-10l0,-60a10,10,0,0,1,10,-10z'
278 | );
279 |
280 |
281 | testScenario(
282 | 'M154,143L338,248 M129,118l50,0l0,50l-50,0z M249,185l80,0a10,10,0,0,1,10,10l0,60a10,10,0,0,1,-10,10l-80,0a10,10,0,0,1,-10,-10l0,-60a10,10,0,0,1,10,-10z'
283 | );
284 |
285 |
286 | testScenario(
287 | 'M221,62L221,104L298,104L249,252 M221,62m0,-18a18,18,0,1,1,0,36a18,18,0,1,1,0,-36z M254,204l80,0a10,10,0,0,1,10,10l0,60a10,10,0,0,1,-10,10l-80,0a10,10,0,0,1,-10,-10l0,-60a10,10,0,0,1,10,-10z'
288 | );
289 |
290 |
291 | testScenario(
292 | 'M423,497L423,340L327,340L350,269 M423,472l25,25l-25,25l-25,-25z M276,202l80,0a10,10,0,0,1,10,10l0,60a10,10,0,0,1,-10,10l-80,0a10,10,0,0,1,-10,-10l0,-60a10,10,0,0,1,10,-10z'
293 | );
294 |
295 |
296 | testScenario(
297 | 'M230,374L248,225 M211,349l36,0l0,50l-36,0z M252,162l80,0a10,10,0,0,1,10,10l0,60a10,10,0,0,1,-10,10l-80,0a10,10,0,0,1,-10,-10l0,-60a10,10,0,0,1,10,-10z'
298 | );
299 |
300 |
301 | testScenario(
302 | 'M402,354L402,219L231,234 M402,329l25,25l-25,25l-25,-25z M223,173l80,0a10,10,0,0,1,10,10l0,60a10,10,0,0,1,-10,10l-80,0a10,10,0,0,1,-10,-10l0,-60a10,10,0,0,1,10,-10z'
303 | );
304 |
305 |
306 | testScenario(
307 | 'M384,214L404,214L404,227L441,227 M295,207l80,0a10,10,0,0,1,10,10l0,60a10,10,0,0,1,-10,10l-80,0a10,10,0,0,1,-10,-10l0,-60a10,10,0,0,1,10,-10z M441,227m0,-18a18,18,0,1,1,0,36a18,18,0,1,1,0,-36z'
308 | );
309 |
310 |
311 | testScenario(
312 | 'M423,497L423,180L300,262 M423,472l25,25l-25,25l-25,-25z M297,233l80,0a10,10,0,0,1,10,10l0,60a10,10,0,0,1,-10,10l-80,0a10,10,0,0,1,-10,-10l0,-60a10,10,0,0,1,10,-10z'
313 | );
314 |
315 | testScenario([
316 | 'M10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80',
317 | 'M10 80 Q 95 10 180 80'
318 | ]);
319 |
320 | testScenario([
321 | 'M10 80 Q 52.5 10, 95 80 T 180 80',
322 | 'M10 315 L 110 215 A 30 50 0 0 1 162.55 162.45',
323 | 'M 100 150 L 172.55 152.45 A 30 50 -45 0 1 215.1 109.9 L 315 10'
324 | ]);
325 |
326 | testScenario([
327 | 'M30 80 A 45 45, 0, 0, 0, 75 125 L 75 80 Z',
328 | 'M30 80 A 45 45, 0, 1, 0, 75 125 L 75 80 Z',
329 | 'M80 30 A 45 45, 0, 0, 1, 125 75 L 125 30 Z',
330 | 'M30 30 A 45 45, 0, 1, 1, 75 75 L 75 30 Z'
331 | ]);
332 |
333 | });
334 |
335 | });
336 |
337 |
338 |
339 | // helpers //////////////////////////////////
340 |
341 | function expectIntersection(intersections, expected) {
342 |
343 | var normalizedIntersections = intersections.map(function(i) {
344 | return {
345 | x: Math.round(i.x),
346 | y: Math.round(i.y),
347 | segment1: i.segment1,
348 | segment2: i.segment2
349 | };
350 | });
351 |
352 | expect(normalizedIntersections).to.eql(expected);
353 | }
354 |
355 | function debug(label, pathArray, intersectionsArray, fail) {
356 |
357 | var colors = [
358 | '#000',
359 | '#AAA',
360 | '#777',
361 | '#333'
362 | ];
363 |
364 | var paths = pathArray.map(function(path, idx) {
365 | return '';
366 | }).join('');
367 |
368 | var points = intersectionsArray.map(function(i) {
369 | if (!i) {
370 | return '';
371 | }
372 |
373 | return i.map(function(p) {
374 | return (
375 | '' +
376 | ''
377 | );
378 | }).join('');
379 | });
380 |
381 | var borderStyle = 'border: solid 3px ' + (fail ? 'red' : 'green');
382 |
383 | var svg = '';
392 |
393 | var svgNode = /** @type {SVGElement} */ (domify(svg));
394 |
395 | // show debug SVG
396 | document.body.appendChild(svgNode);
397 |
398 | // center visible elements group
399 | var group = /** @type {SVGGElement} */ (svgNode.querySelector('g'));
400 |
401 | var bbox = group.getBBox();
402 |
403 | group.setAttribute(
404 | 'transform',
405 | 'translate(' + (-bbox.x + 20) + ', ' + (-bbox.y + 50) + ')'
406 | );
407 | }
408 |
409 |
410 | var counter;
411 |
412 | function testScenario(paths) {
413 |
414 | if (typeof paths === 'string') {
415 | paths = paths.split(/ /g);
416 | }
417 |
418 | counter = (counter || 0) + 1;
419 |
420 | var label = 'scenario #' + counter;
421 |
422 | it(label, function() {
423 |
424 | var intersections = [];
425 |
426 | for (var i = 0; i < paths.length; i++) {
427 | intersections.push(intersect(paths[i], paths[(i + 1) % paths.length]));
428 | }
429 |
430 | debug(label, paths, intersections);
431 | });
432 |
433 | }
434 |
435 | function test(label, options) {
436 | createTest(it, label, options);
437 | }
438 |
439 | // eslint-disable-next-line no-unused-vars
440 | function testOnly(label, options) {
441 | createTest(it.only, label, options);
442 | }
443 |
444 | // eslint-disable-next-line no-unused-vars
445 | function testSkip(label, options) {
446 | createTest(it.skip, label, options);
447 | }
448 |
449 | function createTest(it, label, options) {
450 |
451 | it(label, function() {
452 | var p0 = options.p0,
453 | p1 = options.p1,
454 | expectedIntersections = options.expectedIntersections;
455 |
456 | // guard
457 | expect(p0).to.exist;
458 | expect(p1).to.exist;
459 | expect(expectedIntersections).to.exist;
460 |
461 | // when
462 | var intersections = intersect(p0, p1);
463 | var intersectionsCount = intersect(p0, p1, true);
464 |
465 | var err;
466 |
467 | // then
468 | try {
469 | expectIntersection(intersections, expectedIntersections);
470 |
471 | expect(intersections).to.have.length(intersectionsCount);
472 | } catch (e) {
473 | err = e;
474 | }
475 |
476 | debug(label, [ p0, p1 ], [ intersections ], err);
477 |
478 | if (err) {
479 | throw err;
480 | }
481 | });
482 |
483 | }
484 |
--------------------------------------------------------------------------------
/test/intersect.spec.ts:
--------------------------------------------------------------------------------
1 | import intersect from 'path-intersection';
2 |
3 | import domify from 'domify';
4 |
5 |
6 | describe('path-intersection', function() {
7 |
8 | describe('api', function() {
9 |
10 | var p1 = [ [ 'M', 0, 0 ], [ 'L', 100, 100 ] ];
11 | var p2 = 'M0,100L100,0';
12 |
13 |
14 | it('should support SVG path and component args', function() {
15 |
16 | // when
17 | const intersections = intersect(p1, p2);
18 |
19 | // then
20 | expect(intersections).to.have.length(1);
21 |
22 | // and then
23 | const [ intersection ] = intersections;
24 |
25 | expect(intersection).to.have.keys([
26 | 'segment1',
27 | 'segment2',
28 | 'x',
29 | 'y',
30 | 'bez1',
31 | 'bez2',
32 | 't1',
33 | 't2'
34 | ]);
35 | });
36 |
37 | });
38 |
39 | });
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "module": "Node16",
5 | "moduleResolution": "Node16",
6 | "checkJs": true,
7 | "noImplicitAny": false
8 | },
9 | "include": [
10 | "test"
11 | ]
12 | }
--------------------------------------------------------------------------------