math.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. import { NormalizedZoomValue, Point, Zoom } from "./types";
  2. import {
  3. DEFAULT_ADAPTIVE_RADIUS,
  4. LINE_CONFIRM_THRESHOLD,
  5. DEFAULT_PROPORTIONAL_RADIUS,
  6. ROUNDNESS,
  7. } from "./constants";
  8. import {
  9. ExcalidrawElement,
  10. ExcalidrawLinearElement,
  11. NonDeleted,
  12. } from "./element/types";
  13. import { getShapeForElement } from "./renderer/renderElement";
  14. import { getCurvePathOps } from "./element/bounds";
  15. export const rotate = (
  16. x1: number,
  17. y1: number,
  18. x2: number,
  19. y2: number,
  20. angle: number,
  21. ): [number, number] =>
  22. // 𝑎′𝑥=(𝑎𝑥−𝑐𝑥)cos𝜃−(𝑎𝑦−𝑐𝑦)sin𝜃+𝑐𝑥
  23. // 𝑎′𝑦=(𝑎𝑥−𝑐𝑥)sin𝜃+(𝑎𝑦−𝑐𝑦)cos𝜃+𝑐𝑦.
  24. // https://math.stackexchange.com/questions/2204520/how-do-i-rotate-a-line-segment-in-a-specific-point-on-the-line
  25. [
  26. (x1 - x2) * Math.cos(angle) - (y1 - y2) * Math.sin(angle) + x2,
  27. (x1 - x2) * Math.sin(angle) + (y1 - y2) * Math.cos(angle) + y2,
  28. ];
  29. export const rotatePoint = (
  30. point: Point,
  31. center: Point,
  32. angle: number,
  33. ): [number, number] => rotate(point[0], point[1], center[0], center[1], angle);
  34. export const adjustXYWithRotation = (
  35. sides: {
  36. n?: boolean;
  37. e?: boolean;
  38. s?: boolean;
  39. w?: boolean;
  40. },
  41. x: number,
  42. y: number,
  43. angle: number,
  44. deltaX1: number,
  45. deltaY1: number,
  46. deltaX2: number,
  47. deltaY2: number,
  48. ): [number, number] => {
  49. const cos = Math.cos(angle);
  50. const sin = Math.sin(angle);
  51. if (sides.e && sides.w) {
  52. x += deltaX1 + deltaX2;
  53. } else if (sides.e) {
  54. x += deltaX1 * (1 + cos);
  55. y += deltaX1 * sin;
  56. x += deltaX2 * (1 - cos);
  57. y += deltaX2 * -sin;
  58. } else if (sides.w) {
  59. x += deltaX1 * (1 - cos);
  60. y += deltaX1 * -sin;
  61. x += deltaX2 * (1 + cos);
  62. y += deltaX2 * sin;
  63. }
  64. if (sides.n && sides.s) {
  65. y += deltaY1 + deltaY2;
  66. } else if (sides.n) {
  67. x += deltaY1 * sin;
  68. y += deltaY1 * (1 - cos);
  69. x += deltaY2 * -sin;
  70. y += deltaY2 * (1 + cos);
  71. } else if (sides.s) {
  72. x += deltaY1 * -sin;
  73. y += deltaY1 * (1 + cos);
  74. x += deltaY2 * sin;
  75. y += deltaY2 * (1 - cos);
  76. }
  77. return [x, y];
  78. };
  79. export const getPointOnAPath = (point: Point, path: Point[]) => {
  80. const [px, py] = point;
  81. const [start, ...other] = path;
  82. let [lastX, lastY] = start;
  83. let kLine: number = 0;
  84. let idx: number = 0;
  85. // if any item in the array is true, it means that a point is
  86. // on some segment of a line based path
  87. const retVal = other.some(([x2, y2], i) => {
  88. // we always take a line when dealing with line segments
  89. const x1 = lastX;
  90. const y1 = lastY;
  91. lastX = x2;
  92. lastY = y2;
  93. // if a point is not within the domain of the line segment
  94. // it is not on the line segment
  95. if (px < x1 || px > x2) {
  96. return false;
  97. }
  98. // check if all points lie on the same line
  99. // y1 = kx1 + b, y2 = kx2 + b
  100. // y2 - y1 = k(x2 - x2) -> k = (y2 - y1) / (x2 - x1)
  101. // coefficient for the line (p0, p1)
  102. const kL = (y2 - y1) / (x2 - x1);
  103. // coefficient for the line segment (p0, point)
  104. const kP1 = (py - y1) / (px - x1);
  105. // coefficient for the line segment (point, p1)
  106. const kP2 = (py - y2) / (px - x2);
  107. // because we are basing both lines from the same starting point
  108. // the only option for collinearity is having same coefficients
  109. // using it for floating point comparisons
  110. const epsilon = 0.3;
  111. // if coefficient is more than an arbitrary epsilon,
  112. // these lines are nor collinear
  113. if (Math.abs(kP1 - kL) > epsilon && Math.abs(kP2 - kL) > epsilon) {
  114. return false;
  115. }
  116. // store the coefficient because we are goint to need it
  117. kLine = kL;
  118. idx = i;
  119. return true;
  120. });
  121. // Return a coordinate that is always on the line segment
  122. if (retVal === true) {
  123. return { x: point[0], y: kLine * point[0], segment: idx };
  124. }
  125. return null;
  126. };
  127. export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
  128. const xd = x2 - x1;
  129. const yd = y2 - y1;
  130. return Math.hypot(xd, yd);
  131. };
  132. export const centerPoint = (a: Point, b: Point): Point => {
  133. return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
  134. };
  135. // Checks if the first and last point are close enough
  136. // to be considered a loop
  137. export const isPathALoop = (
  138. points: ExcalidrawLinearElement["points"],
  139. /** supply if you want the loop detection to account for current zoom */
  140. zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
  141. ): boolean => {
  142. if (points.length >= 3) {
  143. const [first, last] = [points[0], points[points.length - 1]];
  144. const distance = distance2d(first[0], first[1], last[0], last[1]);
  145. // Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
  146. // really close we make the threshold smaller, and vice versa.
  147. return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
  148. }
  149. return false;
  150. };
  151. // Draw a line from the point to the right till infiinty
  152. // Check how many lines of the polygon does this infinite line intersects with
  153. // If the number of intersections is odd, point is in the polygon
  154. export const isPointInPolygon = (
  155. points: Point[],
  156. x: number,
  157. y: number,
  158. ): boolean => {
  159. const vertices = points.length;
  160. // There must be at least 3 vertices in polygon
  161. if (vertices < 3) {
  162. return false;
  163. }
  164. const extreme: Point = [Number.MAX_SAFE_INTEGER, y];
  165. const p: Point = [x, y];
  166. let count = 0;
  167. for (let i = 0; i < vertices; i++) {
  168. const current = points[i];
  169. const next = points[(i + 1) % vertices];
  170. if (doSegmentsIntersect(current, next, p, extreme)) {
  171. if (orderedColinearOrientation(current, p, next) === 0) {
  172. return isPointWithinBounds(current, p, next);
  173. }
  174. count++;
  175. }
  176. }
  177. // true if count is off
  178. return count % 2 === 1;
  179. };
  180. // Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
  181. // This is an approximation to "does `q` lie on a segment `pr`" check.
  182. const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
  183. return (
  184. q[0] <= Math.max(p[0], r[0]) &&
  185. q[0] >= Math.min(p[0], r[0]) &&
  186. q[1] <= Math.max(p[1], r[1]) &&
  187. q[1] >= Math.min(p[1], r[1])
  188. );
  189. };
  190. // For the ordered points p, q, r, return
  191. // 0 if p, q, r are colinear
  192. // 1 if Clockwise
  193. // 2 if counterclickwise
  194. const orderedColinearOrientation = (p: Point, q: Point, r: Point) => {
  195. const val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]);
  196. if (val === 0) {
  197. return 0;
  198. }
  199. return val > 0 ? 1 : 2;
  200. };
  201. // Check is p1q1 intersects with p2q2
  202. const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
  203. const o1 = orderedColinearOrientation(p1, q1, p2);
  204. const o2 = orderedColinearOrientation(p1, q1, q2);
  205. const o3 = orderedColinearOrientation(p2, q2, p1);
  206. const o4 = orderedColinearOrientation(p2, q2, q1);
  207. if (o1 !== o2 && o3 !== o4) {
  208. return true;
  209. }
  210. // p1, q1 and p2 are colinear and p2 lies on segment p1q1
  211. if (o1 === 0 && isPointWithinBounds(p1, p2, q1)) {
  212. return true;
  213. }
  214. // p1, q1 and p2 are colinear and q2 lies on segment p1q1
  215. if (o2 === 0 && isPointWithinBounds(p1, q2, q1)) {
  216. return true;
  217. }
  218. // p2, q2 and p1 are colinear and p1 lies on segment p2q2
  219. if (o3 === 0 && isPointWithinBounds(p2, p1, q2)) {
  220. return true;
  221. }
  222. // p2, q2 and q1 are colinear and q1 lies on segment p2q2
  223. if (o4 === 0 && isPointWithinBounds(p2, q1, q2)) {
  224. return true;
  225. }
  226. return false;
  227. };
  228. // TODO: Rounding this point causes some shake when free drawing
  229. export const getGridPoint = (
  230. x: number,
  231. y: number,
  232. gridSize: number | null,
  233. ): [number, number] => {
  234. if (gridSize) {
  235. return [
  236. Math.round(x / gridSize) * gridSize,
  237. Math.round(y / gridSize) * gridSize,
  238. ];
  239. }
  240. return [x, y];
  241. };
  242. export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
  243. if (
  244. element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
  245. element.roundness?.type === ROUNDNESS.LEGACY
  246. ) {
  247. return x * DEFAULT_PROPORTIONAL_RADIUS;
  248. }
  249. if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
  250. const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
  251. const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
  252. if (x <= CUTOFF_SIZE) {
  253. return x * DEFAULT_PROPORTIONAL_RADIUS;
  254. }
  255. return fixedRadiusSize;
  256. }
  257. return 0;
  258. };
  259. export const getControlPointsForBezierCurve = (
  260. element: NonDeleted<ExcalidrawLinearElement>,
  261. endPoint: Point,
  262. ) => {
  263. const shape = getShapeForElement(element as ExcalidrawLinearElement);
  264. if (!shape) {
  265. return null;
  266. }
  267. const ops = getCurvePathOps(shape[0]);
  268. let currentP: Mutable<Point> = [0, 0];
  269. let index = 0;
  270. let minDistance = Infinity;
  271. let controlPoints: Mutable<Point>[] | null = null;
  272. while (index < ops.length) {
  273. const { op, data } = ops[index];
  274. if (op === "move") {
  275. currentP = data as unknown as Mutable<Point>;
  276. }
  277. if (op === "bcurveTo") {
  278. const p0 = currentP;
  279. const p1 = [data[0], data[1]] as Mutable<Point>;
  280. const p2 = [data[2], data[3]] as Mutable<Point>;
  281. const p3 = [data[4], data[5]] as Mutable<Point>;
  282. const distance = distance2d(p3[0], p3[1], endPoint[0], endPoint[1]);
  283. if (distance < minDistance) {
  284. minDistance = distance;
  285. controlPoints = [p0, p1, p2, p3];
  286. }
  287. currentP = p3;
  288. }
  289. index++;
  290. }
  291. return controlPoints;
  292. };
  293. export const getBezierXY = (
  294. p0: Point,
  295. p1: Point,
  296. p2: Point,
  297. p3: Point,
  298. t: number,
  299. ) => {
  300. const equation = (t: number, idx: number) =>
  301. Math.pow(1 - t, 3) * p3[idx] +
  302. 3 * t * Math.pow(1 - t, 2) * p2[idx] +
  303. 3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
  304. p0[idx] * Math.pow(t, 3);
  305. const tx = equation(t, 0);
  306. const ty = equation(t, 1);
  307. return [tx, ty];
  308. };
  309. export const getPointsInBezierCurve = (
  310. element: NonDeleted<ExcalidrawLinearElement>,
  311. endPoint: Point,
  312. ) => {
  313. const controlPoints: Mutable<Point>[] = getControlPointsForBezierCurve(
  314. element,
  315. endPoint,
  316. )!;
  317. if (!controlPoints) {
  318. return [];
  319. }
  320. const pointsOnCurve: Mutable<Point>[] = [];
  321. let t = 1;
  322. // Take 20 points on curve for better accuracy
  323. while (t > 0) {
  324. const point = getBezierXY(
  325. controlPoints[0],
  326. controlPoints[1],
  327. controlPoints[2],
  328. controlPoints[3],
  329. t,
  330. );
  331. pointsOnCurve.push([point[0], point[1]]);
  332. t -= 0.05;
  333. }
  334. if (pointsOnCurve.length) {
  335. if (arePointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
  336. pointsOnCurve.push([endPoint[0], endPoint[1]]);
  337. }
  338. }
  339. return pointsOnCurve;
  340. };
  341. export const getBezierCurveArcLengths = (
  342. element: NonDeleted<ExcalidrawLinearElement>,
  343. endPoint: Point,
  344. ) => {
  345. const arcLengths: number[] = [];
  346. arcLengths[0] = 0;
  347. const points = getPointsInBezierCurve(element, endPoint);
  348. let index = 0;
  349. let distance = 0;
  350. while (index < points.length - 1) {
  351. const segmentDistance = distance2d(
  352. points[index][0],
  353. points[index][1],
  354. points[index + 1][0],
  355. points[index + 1][1],
  356. );
  357. distance += segmentDistance;
  358. arcLengths.push(distance);
  359. index++;
  360. }
  361. return arcLengths;
  362. };
  363. export const getBezierCurveLength = (
  364. element: NonDeleted<ExcalidrawLinearElement>,
  365. endPoint: Point,
  366. ) => {
  367. const arcLengths = getBezierCurveArcLengths(element, endPoint);
  368. return arcLengths.at(-1) as number;
  369. };
  370. // This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
  371. export const mapIntervalToBezierT = (
  372. element: NonDeleted<ExcalidrawLinearElement>,
  373. endPoint: Point,
  374. interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
  375. ) => {
  376. const arcLengths = getBezierCurveArcLengths(element, endPoint);
  377. const pointsCount = arcLengths.length - 1;
  378. const curveLength = arcLengths.at(-1) as number;
  379. const targetLength = interval * curveLength;
  380. let low = 0;
  381. let high = pointsCount;
  382. let index = 0;
  383. // Doing a binary search to find the largest length that is less than the target length
  384. while (low < high) {
  385. index = Math.floor(low + (high - low) / 2);
  386. if (arcLengths[index] < targetLength) {
  387. low = index + 1;
  388. } else {
  389. high = index;
  390. }
  391. }
  392. if (arcLengths[index] > targetLength) {
  393. index--;
  394. }
  395. if (arcLengths[index] === targetLength) {
  396. return index / pointsCount;
  397. }
  398. return (
  399. 1 -
  400. (index +
  401. (targetLength - arcLengths[index]) /
  402. (arcLengths[index + 1] - arcLengths[index])) /
  403. pointsCount
  404. );
  405. };
  406. export const arePointsEqual = (p1: Point, p2: Point) => {
  407. return p1[0] === p2[0] && p1[1] === p2[1];
  408. };