bounds.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. import {
  2. ExcalidrawElement,
  3. ExcalidrawLinearElement,
  4. Arrowhead,
  5. ExcalidrawFreeDrawElement,
  6. NonDeleted,
  7. } from "./types";
  8. import { distance2d, rotate } from "../math";
  9. import rough from "roughjs/bin/rough";
  10. import { Drawable, Op } from "roughjs/bin/core";
  11. import { Point } from "../types";
  12. import {
  13. getShapeForElement,
  14. generateRoughOptions,
  15. } from "../renderer/renderElement";
  16. import { isFreeDrawElement, isLinearElement } from "./typeChecks";
  17. import { rescalePoints } from "../points";
  18. // x and y position of top left corner, x and y position of bottom right corner
  19. export type Bounds = readonly [number, number, number, number];
  20. type MaybeQuadraticSolution = [number | null, number | null] | false;
  21. // If the element is created from right to left, the width is going to be negative
  22. // This set of functions retrieves the absolute position of the 4 points.
  23. export const getElementAbsoluteCoords = (
  24. element: ExcalidrawElement,
  25. ): Bounds => {
  26. if (isFreeDrawElement(element)) {
  27. return getFreeDrawElementAbsoluteCoords(element);
  28. } else if (isLinearElement(element)) {
  29. return getLinearElementAbsoluteCoords(element);
  30. }
  31. return [
  32. element.x,
  33. element.y,
  34. element.x + element.width,
  35. element.y + element.height,
  36. ];
  37. };
  38. export const pointRelativeTo = (
  39. element: ExcalidrawElement,
  40. absoluteCoords: Point,
  41. ): Point => {
  42. return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
  43. };
  44. export const getDiamondPoints = (element: ExcalidrawElement) => {
  45. // Here we add +1 to avoid these numbers to be 0
  46. // otherwise rough.js will throw an error complaining about it
  47. const topX = Math.floor(element.width / 2) + 1;
  48. const topY = 0;
  49. const rightX = element.width;
  50. const rightY = Math.floor(element.height / 2) + 1;
  51. const bottomX = topX;
  52. const bottomY = element.height;
  53. const leftX = 0;
  54. const leftY = rightY;
  55. return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];
  56. };
  57. export const getCurvePathOps = (shape: Drawable): Op[] => {
  58. for (const set of shape.sets) {
  59. if (set.type === "path") {
  60. return set.ops;
  61. }
  62. }
  63. return shape.sets[0].ops;
  64. };
  65. // reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes
  66. const getBezierValueForT = (
  67. t: number,
  68. p0: number,
  69. p1: number,
  70. p2: number,
  71. p3: number,
  72. ) => {
  73. const oneMinusT = 1 - t;
  74. return (
  75. Math.pow(oneMinusT, 3) * p0 +
  76. 3 * Math.pow(oneMinusT, 2) * t * p1 +
  77. 3 * oneMinusT * Math.pow(t, 2) * p2 +
  78. Math.pow(t, 3) * p3
  79. );
  80. };
  81. const solveQuadratic = (
  82. p0: number,
  83. p1: number,
  84. p2: number,
  85. p3: number,
  86. ): MaybeQuadraticSolution => {
  87. const i = p1 - p0;
  88. const j = p2 - p1;
  89. const k = p3 - p2;
  90. const a = 3 * i - 6 * j + 3 * k;
  91. const b = 6 * j - 6 * i;
  92. const c = 3 * i;
  93. const sqrtPart = b * b - 4 * a * c;
  94. const hasSolution = sqrtPart >= 0;
  95. if (!hasSolution) {
  96. return false;
  97. }
  98. const t1 = (-b + Math.sqrt(sqrtPart)) / (2 * a);
  99. const t2 = (-b - Math.sqrt(sqrtPart)) / (2 * a);
  100. let s1 = null;
  101. let s2 = null;
  102. if (t1 >= 0 && t1 <= 1) {
  103. s1 = getBezierValueForT(t1, p0, p1, p2, p3);
  104. }
  105. if (t2 >= 0 && t2 <= 1) {
  106. s2 = getBezierValueForT(t2, p0, p1, p2, p3);
  107. }
  108. return [s1, s2];
  109. };
  110. const getCubicBezierCurveBound = (
  111. p0: Point,
  112. p1: Point,
  113. p2: Point,
  114. p3: Point,
  115. ): Bounds => {
  116. const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
  117. const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
  118. let minX = Math.min(p0[0], p3[0]);
  119. let maxX = Math.max(p0[0], p3[0]);
  120. if (solX) {
  121. const xs = solX.filter((x) => x !== null) as number[];
  122. minX = Math.min(minX, ...xs);
  123. maxX = Math.max(maxX, ...xs);
  124. }
  125. let minY = Math.min(p0[1], p3[1]);
  126. let maxY = Math.max(p0[1], p3[1]);
  127. if (solY) {
  128. const ys = solY.filter((y) => y !== null) as number[];
  129. minY = Math.min(minY, ...ys);
  130. maxY = Math.max(maxY, ...ys);
  131. }
  132. return [minX, minY, maxX, maxY];
  133. };
  134. const getMinMaxXYFromCurvePathOps = (
  135. ops: Op[],
  136. transformXY?: (x: number, y: number) => [number, number],
  137. ): [number, number, number, number] => {
  138. let currentP: Point = [0, 0];
  139. const { minX, minY, maxX, maxY } = ops.reduce(
  140. (limits, { op, data }) => {
  141. // There are only four operation types:
  142. // move, bcurveTo, lineTo, and curveTo
  143. if (op === "move") {
  144. // change starting point
  145. currentP = data as unknown as Point;
  146. // move operation does not draw anything; so, it always
  147. // returns false
  148. } else if (op === "bcurveTo") {
  149. const _p1 = [data[0], data[1]] as Point;
  150. const _p2 = [data[2], data[3]] as Point;
  151. const _p3 = [data[4], data[5]] as Point;
  152. const p1 = transformXY ? transformXY(..._p1) : _p1;
  153. const p2 = transformXY ? transformXY(..._p2) : _p2;
  154. const p3 = transformXY ? transformXY(..._p3) : _p3;
  155. const p0 = transformXY ? transformXY(...currentP) : currentP;
  156. currentP = _p3;
  157. const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
  158. p0,
  159. p1,
  160. p2,
  161. p3,
  162. );
  163. limits.minX = Math.min(limits.minX, minX);
  164. limits.minY = Math.min(limits.minY, minY);
  165. limits.maxX = Math.max(limits.maxX, maxX);
  166. limits.maxY = Math.max(limits.maxY, maxY);
  167. } else if (op === "lineTo") {
  168. // TODO: Implement this
  169. } else if (op === "qcurveTo") {
  170. // TODO: Implement this
  171. }
  172. return limits;
  173. },
  174. { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
  175. );
  176. return [minX, minY, maxX, maxY];
  177. };
  178. const getBoundsFromPoints = (
  179. points: ExcalidrawFreeDrawElement["points"],
  180. ): [number, number, number, number] => {
  181. let minX = Infinity;
  182. let minY = Infinity;
  183. let maxX = -Infinity;
  184. let maxY = -Infinity;
  185. for (const [x, y] of points) {
  186. minX = Math.min(minX, x);
  187. minY = Math.min(minY, y);
  188. maxX = Math.max(maxX, x);
  189. maxY = Math.max(maxY, y);
  190. }
  191. return [minX, minY, maxX, maxY];
  192. };
  193. const getFreeDrawElementAbsoluteCoords = (
  194. element: ExcalidrawFreeDrawElement,
  195. ): [number, number, number, number] => {
  196. const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
  197. return [
  198. minX + element.x,
  199. minY + element.y,
  200. maxX + element.x,
  201. maxY + element.y,
  202. ];
  203. };
  204. const getLinearElementAbsoluteCoords = (
  205. element: ExcalidrawLinearElement,
  206. ): [number, number, number, number] => {
  207. let coords: [number, number, number, number];
  208. if (element.points.length < 2 || !getShapeForElement(element)) {
  209. // XXX this is just a poor estimate and not very useful
  210. const { minX, minY, maxX, maxY } = element.points.reduce(
  211. (limits, [x, y]) => {
  212. limits.minY = Math.min(limits.minY, y);
  213. limits.minX = Math.min(limits.minX, x);
  214. limits.maxX = Math.max(limits.maxX, x);
  215. limits.maxY = Math.max(limits.maxY, y);
  216. return limits;
  217. },
  218. { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
  219. );
  220. coords = [
  221. minX + element.x,
  222. minY + element.y,
  223. maxX + element.x,
  224. maxY + element.y,
  225. ];
  226. } else {
  227. const shape = getShapeForElement(element)!;
  228. // first element is always the curve
  229. const ops = getCurvePathOps(shape[0]);
  230. const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
  231. coords = [
  232. minX + element.x,
  233. minY + element.y,
  234. maxX + element.x,
  235. maxY + element.y,
  236. ];
  237. }
  238. return coords;
  239. };
  240. export const getArrowheadPoints = (
  241. element: ExcalidrawLinearElement,
  242. shape: Drawable[],
  243. position: "start" | "end",
  244. arrowhead: Arrowhead,
  245. ) => {
  246. const ops = getCurvePathOps(shape[0]);
  247. if (ops.length < 1) {
  248. return null;
  249. }
  250. // The index of the bCurve operation to examine.
  251. const index = position === "start" ? 1 : ops.length - 1;
  252. const data = ops[index].data;
  253. const p3 = [data[4], data[5]] as Point;
  254. const p2 = [data[2], data[3]] as Point;
  255. const p1 = [data[0], data[1]] as Point;
  256. // We need to find p0 of the bezier curve.
  257. // It is typically the last point of the previous
  258. // curve; it can also be the position of moveTo operation.
  259. const prevOp = ops[index - 1];
  260. let p0: Point = [0, 0];
  261. if (prevOp.op === "move") {
  262. p0 = prevOp.data as unknown as Point;
  263. } else if (prevOp.op === "bcurveTo") {
  264. p0 = [prevOp.data[4], prevOp.data[5]];
  265. }
  266. // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
  267. const equation = (t: number, idx: number) =>
  268. Math.pow(1 - t, 3) * p3[idx] +
  269. 3 * t * Math.pow(1 - t, 2) * p2[idx] +
  270. 3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
  271. p0[idx] * Math.pow(t, 3);
  272. // Ee know the last point of the arrow (or the first, if start arrowhead).
  273. const [x2, y2] = position === "start" ? p0 : p3;
  274. // By using cubic bezier equation (B(t)) and the given parameters,
  275. // we calculate a point that is closer to the last point.
  276. // The value 0.3 is chosen arbitrarily and it works best for all
  277. // the tested cases.
  278. const [x1, y1] = [equation(0.3, 0), equation(0.3, 1)];
  279. // Find the normalized direction vector based on the
  280. // previously calculated points.
  281. const distance = Math.hypot(x2 - x1, y2 - y1);
  282. const nx = (x2 - x1) / distance;
  283. const ny = (y2 - y1) / distance;
  284. const size = {
  285. arrow: 30,
  286. bar: 15,
  287. dot: 15,
  288. triangle: 15,
  289. }[arrowhead]; // pixels (will differ for each arrowhead)
  290. let length = 0;
  291. if (arrowhead === "arrow") {
  292. // Length for -> arrows is based on the length of the last section
  293. const [cx, cy] = element.points[element.points.length - 1];
  294. const [px, py] =
  295. element.points.length > 1
  296. ? element.points[element.points.length - 2]
  297. : [0, 0];
  298. length = Math.hypot(cx - px, cy - py);
  299. } else {
  300. // Length for other arrowhead types is based on the total length of the line
  301. for (let i = 0; i < element.points.length; i++) {
  302. const [px, py] = element.points[i - 1] || [0, 0];
  303. const [cx, cy] = element.points[i];
  304. length += Math.hypot(cx - px, cy - py);
  305. }
  306. }
  307. // Scale down the arrowhead until we hit a certain size so that it doesn't look weird.
  308. // This value is selected by minimizing a minimum size with the last segment of the arrowhead
  309. const minSize = Math.min(size, length / 2);
  310. const xs = x2 - nx * minSize;
  311. const ys = y2 - ny * minSize;
  312. if (arrowhead === "dot") {
  313. const r = Math.hypot(ys - y2, xs - x2) + element.strokeWidth;
  314. return [x2, y2, r];
  315. }
  316. const angle = {
  317. arrow: 20,
  318. bar: 90,
  319. triangle: 25,
  320. }[arrowhead]; // degrees
  321. // Return points
  322. const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
  323. const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
  324. return [x2, y2, x3, y3, x4, y4];
  325. };
  326. const getLinearElementRotatedBounds = (
  327. element: ExcalidrawLinearElement,
  328. cx: number,
  329. cy: number,
  330. ): [number, number, number, number] => {
  331. if (element.points.length < 2 || !getShapeForElement(element)) {
  332. // XXX this is just a poor estimate and not very useful
  333. const { minX, minY, maxX, maxY } = element.points.reduce(
  334. (limits, [x, y]) => {
  335. [x, y] = rotate(element.x + x, element.y + y, cx, cy, element.angle);
  336. limits.minY = Math.min(limits.minY, y);
  337. limits.minX = Math.min(limits.minX, x);
  338. limits.maxX = Math.max(limits.maxX, x);
  339. limits.maxY = Math.max(limits.maxY, y);
  340. return limits;
  341. },
  342. { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
  343. );
  344. return [minX, minY, maxX, maxY];
  345. }
  346. const shape = getShapeForElement(element)!;
  347. // first element is always the curve
  348. const ops = getCurvePathOps(shape[0]);
  349. const transformXY = (x: number, y: number) =>
  350. rotate(element.x + x, element.y + y, cx, cy, element.angle);
  351. return getMinMaxXYFromCurvePathOps(ops, transformXY);
  352. };
  353. // We could cache this stuff
  354. export const getElementBounds = (
  355. element: ExcalidrawElement,
  356. ): [number, number, number, number] => {
  357. let bounds: [number, number, number, number];
  358. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  359. const cx = (x1 + x2) / 2;
  360. const cy = (y1 + y2) / 2;
  361. if (isFreeDrawElement(element)) {
  362. const [minX, minY, maxX, maxY] = getBoundsFromPoints(
  363. element.points.map(([x, y]) =>
  364. rotate(x, y, cx - element.x, cy - element.y, element.angle),
  365. ),
  366. );
  367. return [
  368. minX + element.x,
  369. minY + element.y,
  370. maxX + element.x,
  371. maxY + element.y,
  372. ];
  373. } else if (isLinearElement(element)) {
  374. bounds = getLinearElementRotatedBounds(element, cx, cy);
  375. } else if (element.type === "diamond") {
  376. const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
  377. const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
  378. const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
  379. const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
  380. const minX = Math.min(x11, x12, x22, x21);
  381. const minY = Math.min(y11, y12, y22, y21);
  382. const maxX = Math.max(x11, x12, x22, x21);
  383. const maxY = Math.max(y11, y12, y22, y21);
  384. bounds = [minX, minY, maxX, maxY];
  385. } else if (element.type === "ellipse") {
  386. const w = (x2 - x1) / 2;
  387. const h = (y2 - y1) / 2;
  388. const cos = Math.cos(element.angle);
  389. const sin = Math.sin(element.angle);
  390. const ww = Math.hypot(w * cos, h * sin);
  391. const hh = Math.hypot(h * cos, w * sin);
  392. bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
  393. } else {
  394. const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
  395. const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
  396. const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
  397. const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
  398. const minX = Math.min(x11, x12, x22, x21);
  399. const minY = Math.min(y11, y12, y22, y21);
  400. const maxX = Math.max(x11, x12, x22, x21);
  401. const maxY = Math.max(y11, y12, y22, y21);
  402. bounds = [minX, minY, maxX, maxY];
  403. }
  404. return bounds;
  405. };
  406. export const getCommonBounds = (
  407. elements: readonly ExcalidrawElement[],
  408. ): [number, number, number, number] => {
  409. if (!elements.length) {
  410. return [0, 0, 0, 0];
  411. }
  412. let minX = Infinity;
  413. let maxX = -Infinity;
  414. let minY = Infinity;
  415. let maxY = -Infinity;
  416. elements.forEach((element) => {
  417. const [x1, y1, x2, y2] = getElementBounds(element);
  418. minX = Math.min(minX, x1);
  419. minY = Math.min(minY, y1);
  420. maxX = Math.max(maxX, x2);
  421. maxY = Math.max(maxY, y2);
  422. });
  423. return [minX, minY, maxX, maxY];
  424. };
  425. export const getResizedElementAbsoluteCoords = (
  426. element: ExcalidrawElement,
  427. nextWidth: number,
  428. nextHeight: number,
  429. ): [number, number, number, number] => {
  430. if (!(isLinearElement(element) || isFreeDrawElement(element))) {
  431. return [
  432. element.x,
  433. element.y,
  434. element.x + nextWidth,
  435. element.y + nextHeight,
  436. ];
  437. }
  438. const points = rescalePoints(
  439. 0,
  440. nextWidth,
  441. rescalePoints(1, nextHeight, element.points),
  442. );
  443. let bounds: [number, number, number, number];
  444. if (isFreeDrawElement(element)) {
  445. // Free Draw
  446. bounds = getBoundsFromPoints(points);
  447. } else {
  448. // Line
  449. const gen = rough.generator();
  450. const curve =
  451. element.strokeSharpness === "sharp"
  452. ? gen.linearPath(
  453. points as [number, number][],
  454. generateRoughOptions(element),
  455. )
  456. : gen.curve(
  457. points as [number, number][],
  458. generateRoughOptions(element),
  459. );
  460. const ops = getCurvePathOps(curve);
  461. bounds = getMinMaxXYFromCurvePathOps(ops);
  462. }
  463. const [minX, minY, maxX, maxY] = bounds;
  464. return [
  465. minX + element.x,
  466. minY + element.y,
  467. maxX + element.x,
  468. maxY + element.y,
  469. ];
  470. };
  471. export const getElementPointsCoords = (
  472. element: ExcalidrawLinearElement,
  473. points: readonly (readonly [number, number])[],
  474. sharpness: ExcalidrawElement["strokeSharpness"],
  475. ): [number, number, number, number] => {
  476. // This might be computationally heavey
  477. const gen = rough.generator();
  478. const curve =
  479. sharpness === "sharp"
  480. ? gen.linearPath(
  481. points as [number, number][],
  482. generateRoughOptions(element),
  483. )
  484. : gen.curve(points as [number, number][], generateRoughOptions(element));
  485. const ops = getCurvePathOps(curve);
  486. const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
  487. return [
  488. minX + element.x,
  489. minY + element.y,
  490. maxX + element.x,
  491. maxY + element.y,
  492. ];
  493. };
  494. export const getClosestElementBounds = (
  495. elements: readonly ExcalidrawElement[],
  496. from: { x: number; y: number },
  497. ): [number, number, number, number] => {
  498. if (!elements.length) {
  499. return [0, 0, 0, 0];
  500. }
  501. let minDistance = Infinity;
  502. let closestElement = elements[0];
  503. elements.forEach((element) => {
  504. const [x1, y1, x2, y2] = getElementBounds(element);
  505. const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y);
  506. if (distance < minDistance) {
  507. minDistance = distance;
  508. closestElement = element;
  509. }
  510. });
  511. return getElementBounds(closestElement);
  512. };
  513. export interface Box {
  514. minX: number;
  515. minY: number;
  516. maxX: number;
  517. maxY: number;
  518. midX: number;
  519. midY: number;
  520. width: number;
  521. height: number;
  522. }
  523. export const getCommonBoundingBox = (
  524. elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
  525. ): Box => {
  526. const [minX, minY, maxX, maxY] = getCommonBounds(elements);
  527. return {
  528. minX,
  529. minY,
  530. maxX,
  531. maxY,
  532. width: maxX - minX,
  533. height: maxY - minY,
  534. midX: (minX + maxX) / 2,
  535. midY: (minY + maxY) / 2,
  536. };
  537. };