bounds.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. import { ExcalidrawElement, ExcalidrawLinearElement, Arrowhead } from "./types";
  2. import { distance2d, rotate } from "../math";
  3. import rough from "roughjs/bin/rough";
  4. import { Drawable, Op } from "roughjs/bin/core";
  5. import { Point } from "../types";
  6. import {
  7. getShapeForElement,
  8. generateRoughOptions,
  9. } from "../renderer/renderElement";
  10. import { isLinearElement } from "./typeChecks";
  11. import { rescalePoints } from "../points";
  12. // x and y position of top left corner, x and y position of bottom right corner
  13. export type Bounds = readonly [number, number, number, number];
  14. // If the element is created from right to left, the width is going to be negative
  15. // This set of functions retrieves the absolute position of the 4 points.
  16. export const getElementAbsoluteCoords = (
  17. element: ExcalidrawElement,
  18. ): Bounds => {
  19. if (isLinearElement(element)) {
  20. return getLinearElementAbsoluteCoords(element);
  21. }
  22. return [
  23. element.x,
  24. element.y,
  25. element.x + element.width,
  26. element.y + element.height,
  27. ];
  28. };
  29. export const pointRelativeTo = (
  30. element: ExcalidrawElement,
  31. absoluteCoords: Point,
  32. ): Point => {
  33. return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
  34. };
  35. export const getDiamondPoints = (element: ExcalidrawElement) => {
  36. // Here we add +1 to avoid these numbers to be 0
  37. // otherwise rough.js will throw an error complaining about it
  38. const topX = Math.floor(element.width / 2) + 1;
  39. const topY = 0;
  40. const rightX = element.width;
  41. const rightY = Math.floor(element.height / 2) + 1;
  42. const bottomX = topX;
  43. const bottomY = element.height;
  44. const leftX = 0;
  45. const leftY = rightY;
  46. return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];
  47. };
  48. export const getCurvePathOps = (shape: Drawable): Op[] => {
  49. for (const set of shape.sets) {
  50. if (set.type === "path") {
  51. return set.ops;
  52. }
  53. }
  54. return shape.sets[0].ops;
  55. };
  56. const getMinMaxXYFromCurvePathOps = (
  57. ops: Op[],
  58. transformXY?: (x: number, y: number) => [number, number],
  59. ): [number, number, number, number] => {
  60. let currentP: Point = [0, 0];
  61. const { minX, minY, maxX, maxY } = ops.reduce(
  62. (limits, { op, data }) => {
  63. // There are only four operation types:
  64. // move, bcurveTo, lineTo, and curveTo
  65. if (op === "move") {
  66. // change starting point
  67. currentP = (data as unknown) as Point;
  68. // move operation does not draw anything; so, it always
  69. // returns false
  70. } else if (op === "bcurveTo") {
  71. // create points from bezier curve
  72. // bezier curve stores data as a flattened array of three positions
  73. // [x1, y1, x2, y2, x3, y3]
  74. const p1 = [data[0], data[1]] as Point;
  75. const p2 = [data[2], data[3]] as Point;
  76. const p3 = [data[4], data[5]] as Point;
  77. const p0 = currentP;
  78. currentP = p3;
  79. const equation = (t: number, idx: number) =>
  80. Math.pow(1 - t, 3) * p3[idx] +
  81. 3 * t * Math.pow(1 - t, 2) * p2[idx] +
  82. 3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
  83. p0[idx] * Math.pow(t, 3);
  84. let t = 0;
  85. while (t <= 1.0) {
  86. let x = equation(t, 0);
  87. let y = equation(t, 1);
  88. if (transformXY) {
  89. [x, y] = transformXY(x, y);
  90. }
  91. limits.minY = Math.min(limits.minY, y);
  92. limits.minX = Math.min(limits.minX, x);
  93. limits.maxX = Math.max(limits.maxX, x);
  94. limits.maxY = Math.max(limits.maxY, y);
  95. t += 0.1;
  96. }
  97. } else if (op === "lineTo") {
  98. // TODO: Implement this
  99. } else if (op === "qcurveTo") {
  100. // TODO: Implement this
  101. }
  102. return limits;
  103. },
  104. { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
  105. );
  106. return [minX, minY, maxX, maxY];
  107. };
  108. const getLinearElementAbsoluteCoords = (
  109. element: ExcalidrawLinearElement,
  110. ): [number, number, number, number] => {
  111. if (element.points.length < 2 || !getShapeForElement(element)) {
  112. // XXX this is just a poor estimate and not very useful
  113. const { minX, minY, maxX, maxY } = element.points.reduce(
  114. (limits, [x, y]) => {
  115. limits.minY = Math.min(limits.minY, y);
  116. limits.minX = Math.min(limits.minX, x);
  117. limits.maxX = Math.max(limits.maxX, x);
  118. limits.maxY = Math.max(limits.maxY, y);
  119. return limits;
  120. },
  121. { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
  122. );
  123. return [
  124. minX + element.x,
  125. minY + element.y,
  126. maxX + element.x,
  127. maxY + element.y,
  128. ];
  129. }
  130. const shape = getShapeForElement(element) as Drawable[];
  131. // first element is always the curve
  132. const ops = getCurvePathOps(shape[0]);
  133. const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
  134. return [
  135. minX + element.x,
  136. minY + element.y,
  137. maxX + element.x,
  138. maxY + element.y,
  139. ];
  140. };
  141. export const getArrowheadPoints = (
  142. element: ExcalidrawLinearElement,
  143. shape: Drawable[],
  144. position: "start" | "end",
  145. arrowhead: Arrowhead,
  146. ) => {
  147. const ops = getCurvePathOps(shape[0]);
  148. if (ops.length < 1) {
  149. return null;
  150. }
  151. // The index of the bCurve operation to examine.
  152. const index = position === "start" ? 1 : ops.length - 1;
  153. const data = ops[index].data;
  154. const p3 = [data[4], data[5]] as Point;
  155. const p2 = [data[2], data[3]] as Point;
  156. const p1 = [data[0], data[1]] as Point;
  157. // We need to find p0 of the bezier curve.
  158. // It is typically the last point of the previous
  159. // curve; it can also be the position of moveTo operation.
  160. const prevOp = ops[index - 1];
  161. let p0: Point = [0, 0];
  162. if (prevOp.op === "move") {
  163. p0 = (prevOp.data as unknown) as Point;
  164. } else if (prevOp.op === "bcurveTo") {
  165. p0 = [prevOp.data[4], prevOp.data[5]];
  166. }
  167. // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
  168. const equation = (t: number, idx: number) =>
  169. Math.pow(1 - t, 3) * p3[idx] +
  170. 3 * t * Math.pow(1 - t, 2) * p2[idx] +
  171. 3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
  172. p0[idx] * Math.pow(t, 3);
  173. // Ee know the last point of the arrow (or the first, if start arrowhead).
  174. const [x2, y2] = position === "start" ? p0 : p3;
  175. // By using cubic bezier equation (B(t)) and the given parameters,
  176. // we calculate a point that is closer to the last point.
  177. // The value 0.3 is chosen arbitrarily and it works best for all
  178. // the tested cases.
  179. const [x1, y1] = [equation(0.3, 0), equation(0.3, 1)];
  180. // Find the normalized direction vector based on the
  181. // previously calculated points.
  182. const distance = Math.hypot(x2 - x1, y2 - y1);
  183. const nx = (x2 - x1) / distance;
  184. const ny = (y2 - y1) / distance;
  185. const size = {
  186. arrow: 30,
  187. bar: 15,
  188. dot: 15,
  189. }[arrowhead]; // pixels (will differ for each arrowhead)
  190. const length = element.points.reduce((total, [cx, cy], idx, points) => {
  191. const [px, py] = idx > 0 ? points[idx - 1] : [0, 0];
  192. return total + Math.hypot(cx - px, cy - py);
  193. }, 0);
  194. // Scale down the arrowhead until we hit a certain size so that it doesn't look weird.
  195. // This value is selected by minimizing a minimum size with the whole length of the
  196. // arrowhead instead of last segment of the arrowhead.
  197. const minSize = Math.min(size, length / 2);
  198. const xs = x2 - nx * minSize;
  199. const ys = y2 - ny * minSize;
  200. if (arrowhead === "dot") {
  201. const r = Math.hypot(ys - y2, xs - x2);
  202. return [x2, y2, r];
  203. }
  204. const angle = {
  205. arrow: 20,
  206. bar: 90,
  207. }[arrowhead]; // degrees
  208. // Return points
  209. const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
  210. const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
  211. return [x2, y2, x3, y3, x4, y4];
  212. };
  213. const getLinearElementRotatedBounds = (
  214. element: ExcalidrawLinearElement,
  215. cx: number,
  216. cy: number,
  217. ): [number, number, number, number] => {
  218. if (element.points.length < 2 || !getShapeForElement(element)) {
  219. // XXX this is just a poor estimate and not very useful
  220. const { minX, minY, maxX, maxY } = element.points.reduce(
  221. (limits, [x, y]) => {
  222. [x, y] = rotate(element.x + x, element.y + y, cx, cy, element.angle);
  223. limits.minY = Math.min(limits.minY, y);
  224. limits.minX = Math.min(limits.minX, x);
  225. limits.maxX = Math.max(limits.maxX, x);
  226. limits.maxY = Math.max(limits.maxY, y);
  227. return limits;
  228. },
  229. { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
  230. );
  231. return [minX, minY, maxX, maxY];
  232. }
  233. const shape = getShapeForElement(element) as Drawable[];
  234. // first element is always the curve
  235. const ops = getCurvePathOps(shape[0]);
  236. const transformXY = (x: number, y: number) =>
  237. rotate(element.x + x, element.y + y, cx, cy, element.angle);
  238. return getMinMaxXYFromCurvePathOps(ops, transformXY);
  239. };
  240. export const getElementBounds = (
  241. element: ExcalidrawElement,
  242. ): [number, number, number, number] => {
  243. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  244. const cx = (x1 + x2) / 2;
  245. const cy = (y1 + y2) / 2;
  246. if (isLinearElement(element)) {
  247. return getLinearElementRotatedBounds(element, cx, cy);
  248. }
  249. if (element.type === "diamond") {
  250. const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
  251. const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
  252. const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
  253. const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
  254. const minX = Math.min(x11, x12, x22, x21);
  255. const minY = Math.min(y11, y12, y22, y21);
  256. const maxX = Math.max(x11, x12, x22, x21);
  257. const maxY = Math.max(y11, y12, y22, y21);
  258. return [minX, minY, maxX, maxY];
  259. }
  260. if (element.type === "ellipse") {
  261. const w = (x2 - x1) / 2;
  262. const h = (y2 - y1) / 2;
  263. const cos = Math.cos(element.angle);
  264. const sin = Math.sin(element.angle);
  265. const ww = Math.hypot(w * cos, h * sin);
  266. const hh = Math.hypot(h * cos, w * sin);
  267. return [cx - ww, cy - hh, cx + ww, cy + hh];
  268. }
  269. const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
  270. const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
  271. const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
  272. const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
  273. const minX = Math.min(x11, x12, x22, x21);
  274. const minY = Math.min(y11, y12, y22, y21);
  275. const maxX = Math.max(x11, x12, x22, x21);
  276. const maxY = Math.max(y11, y12, y22, y21);
  277. return [minX, minY, maxX, maxY];
  278. };
  279. export const getCommonBounds = (
  280. elements: readonly ExcalidrawElement[],
  281. ): [number, number, number, number] => {
  282. if (!elements.length) {
  283. return [0, 0, 0, 0];
  284. }
  285. let minX = Infinity;
  286. let maxX = -Infinity;
  287. let minY = Infinity;
  288. let maxY = -Infinity;
  289. elements.forEach((element) => {
  290. const [x1, y1, x2, y2] = getElementBounds(element);
  291. minX = Math.min(minX, x1);
  292. minY = Math.min(minY, y1);
  293. maxX = Math.max(maxX, x2);
  294. maxY = Math.max(maxY, y2);
  295. });
  296. return [minX, minY, maxX, maxY];
  297. };
  298. export const getResizedElementAbsoluteCoords = (
  299. element: ExcalidrawElement,
  300. nextWidth: number,
  301. nextHeight: number,
  302. ): [number, number, number, number] => {
  303. if (!isLinearElement(element)) {
  304. return [
  305. element.x,
  306. element.y,
  307. element.x + nextWidth,
  308. element.y + nextHeight,
  309. ];
  310. }
  311. const points = rescalePoints(
  312. 0,
  313. nextWidth,
  314. rescalePoints(1, nextHeight, element.points),
  315. );
  316. const gen = rough.generator();
  317. const curve =
  318. element.strokeSharpness === "sharp"
  319. ? gen.linearPath(
  320. points as [number, number][],
  321. generateRoughOptions(element),
  322. )
  323. : gen.curve(points as [number, number][], generateRoughOptions(element));
  324. const ops = getCurvePathOps(curve);
  325. const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
  326. return [
  327. minX + element.x,
  328. minY + element.y,
  329. maxX + element.x,
  330. maxY + element.y,
  331. ];
  332. };
  333. export const getElementPointsCoords = (
  334. element: ExcalidrawLinearElement,
  335. points: readonly (readonly [number, number])[],
  336. sharpness: ExcalidrawElement["strokeSharpness"],
  337. ): [number, number, number, number] => {
  338. // This might be computationally heavey
  339. const gen = rough.generator();
  340. const curve =
  341. sharpness === "sharp"
  342. ? gen.linearPath(
  343. points as [number, number][],
  344. generateRoughOptions(element),
  345. )
  346. : gen.curve(points as [number, number][], generateRoughOptions(element));
  347. const ops = getCurvePathOps(curve);
  348. const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
  349. return [
  350. minX + element.x,
  351. minY + element.y,
  352. maxX + element.x,
  353. maxY + element.y,
  354. ];
  355. };
  356. export const getClosestElementBounds = (
  357. elements: readonly ExcalidrawElement[],
  358. from: { x: number; y: number },
  359. ): [number, number, number, number] => {
  360. if (!elements.length) {
  361. return [0, 0, 0, 0];
  362. }
  363. let minDistance = Infinity;
  364. let closestElement = elements[0];
  365. elements.forEach((element) => {
  366. const [x1, y1, x2, y2] = getElementBounds(element);
  367. const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y);
  368. if (distance < minDistance) {
  369. minDistance = distance;
  370. closestElement = element;
  371. }
  372. });
  373. return getElementBounds(closestElement);
  374. };