├── .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 | [![CI](https://github.com/bpmn-io/path-intersection/workflows/CI/badge.svg)](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 | Intersection examples 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 = '' + 384 | '' + 385 | label + 386 | '' + 387 | '' + 388 | paths + 389 | points + 390 | '' + 391 | ''; 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 | } --------------------------------------------------------------------------------