shape.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. /**
  2. * this file defines pure geometric shapes
  3. *
  4. * for instance, a cubic bezier curve is specified by its four control points and
  5. * an ellipse is defined by its center, angle, semi major axis and semi minor axis
  6. * (but in semi-width and semi-height so it's more relevant to Excalidraw)
  7. *
  8. * the idea with pure shapes is so that we can provide collision and other geoemtric methods not depending on
  9. * the specifics of roughjs or elements in Excalidraw; instead, we can focus on the pure shapes themselves
  10. *
  11. * also included in this file are methods for converting an Excalidraw element or a Drawable from roughjs
  12. * to pure shapes
  13. */
  14. import { pointsOnBezierCurves } from "points-on-curve";
  15. import { invariant } from "@excalidraw/common";
  16. import {
  17. curve,
  18. lineSegment,
  19. pointFrom,
  20. pointDistance,
  21. pointFromArray,
  22. pointFromVector,
  23. pointRotateRads,
  24. polygon,
  25. polygonFromPoints,
  26. PRECISION,
  27. segmentsIntersectAt,
  28. vector,
  29. vectorAdd,
  30. vectorFromPoint,
  31. vectorScale,
  32. type GlobalPoint,
  33. type LocalPoint,
  34. } from "@excalidraw/math";
  35. import { getElementAbsoluteCoords } from "@excalidraw/element";
  36. import type {
  37. ElementsMap,
  38. ExcalidrawBindableElement,
  39. ExcalidrawDiamondElement,
  40. ExcalidrawElement,
  41. ExcalidrawEllipseElement,
  42. ExcalidrawEmbeddableElement,
  43. ExcalidrawFrameLikeElement,
  44. ExcalidrawFreeDrawElement,
  45. ExcalidrawIframeElement,
  46. ExcalidrawImageElement,
  47. ExcalidrawLinearElement,
  48. ExcalidrawRectangleElement,
  49. ExcalidrawSelectionElement,
  50. ExcalidrawTextElement,
  51. } from "@excalidraw/element/types";
  52. import type { Curve, LineSegment, Polygon, Radians } from "@excalidraw/math";
  53. import type { Drawable, Op } from "roughjs/bin/core";
  54. // a polyline (made up term here) is a line consisting of other line segments
  55. // this corresponds to a straight line element in the editor but it could also
  56. // be used to model other elements
  57. export type Polyline<Point extends GlobalPoint | LocalPoint> =
  58. LineSegment<Point>[];
  59. // a polycurve is a curve consisting of ther curves, this corresponds to a complex
  60. // curve on the canvas
  61. export type Polycurve<Point extends GlobalPoint | LocalPoint> = Curve<Point>[];
  62. // an ellipse is specified by its center, angle, and its major and minor axes
  63. // but for the sake of simplicity, we've used halfWidth and halfHeight instead
  64. // in replace of semi major and semi minor axes
  65. export type Ellipse<Point extends GlobalPoint | LocalPoint> = {
  66. center: Point;
  67. angle: Radians;
  68. halfWidth: number;
  69. halfHeight: number;
  70. };
  71. export type GeometricShape<Point extends GlobalPoint | LocalPoint> =
  72. | {
  73. type: "line";
  74. data: LineSegment<Point>;
  75. }
  76. | {
  77. type: "polygon";
  78. data: Polygon<Point>;
  79. }
  80. | {
  81. type: "curve";
  82. data: Curve<Point>;
  83. }
  84. | {
  85. type: "ellipse";
  86. data: Ellipse<Point>;
  87. }
  88. | {
  89. type: "polyline";
  90. data: Polyline<Point>;
  91. }
  92. | {
  93. type: "polycurve";
  94. data: Polycurve<Point>;
  95. };
  96. type RectangularElement =
  97. | ExcalidrawRectangleElement
  98. | ExcalidrawDiamondElement
  99. | ExcalidrawFrameLikeElement
  100. | ExcalidrawEmbeddableElement
  101. | ExcalidrawImageElement
  102. | ExcalidrawIframeElement
  103. | ExcalidrawTextElement
  104. | ExcalidrawSelectionElement;
  105. // polygon
  106. export const getPolygonShape = <Point extends GlobalPoint | LocalPoint>(
  107. element: RectangularElement,
  108. ): GeometricShape<Point> => {
  109. const { angle, width, height, x, y } = element;
  110. const cx = x + width / 2;
  111. const cy = y + height / 2;
  112. const center: Point = pointFrom(cx, cy);
  113. let data: Polygon<Point>;
  114. if (element.type === "diamond") {
  115. data = polygon(
  116. pointRotateRads(pointFrom(cx, y), center, angle),
  117. pointRotateRads(pointFrom(x + width, cy), center, angle),
  118. pointRotateRads(pointFrom(cx, y + height), center, angle),
  119. pointRotateRads(pointFrom(x, cy), center, angle),
  120. );
  121. } else {
  122. data = polygon(
  123. pointRotateRads(pointFrom(x, y), center, angle),
  124. pointRotateRads(pointFrom(x + width, y), center, angle),
  125. pointRotateRads(pointFrom(x + width, y + height), center, angle),
  126. pointRotateRads(pointFrom(x, y + height), center, angle),
  127. );
  128. }
  129. return {
  130. type: "polygon",
  131. data,
  132. };
  133. };
  134. // return the selection box for an element, possibly rotated as well
  135. export const getSelectionBoxShape = <Point extends GlobalPoint | LocalPoint>(
  136. element: ExcalidrawElement,
  137. elementsMap: ElementsMap,
  138. padding = 10,
  139. ) => {
  140. let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
  141. element,
  142. elementsMap,
  143. true,
  144. );
  145. x1 -= padding;
  146. x2 += padding;
  147. y1 -= padding;
  148. y2 += padding;
  149. //const angleInDegrees = angleToDegrees(element.angle);
  150. const center = pointFrom(cx, cy);
  151. const topLeft = pointRotateRads(pointFrom(x1, y1), center, element.angle);
  152. const topRight = pointRotateRads(pointFrom(x2, y1), center, element.angle);
  153. const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, element.angle);
  154. const bottomRight = pointRotateRads(pointFrom(x2, y2), center, element.angle);
  155. return {
  156. type: "polygon",
  157. data: [topLeft, topRight, bottomRight, bottomLeft],
  158. } as GeometricShape<Point>;
  159. };
  160. // ellipse
  161. export const getEllipseShape = <Point extends GlobalPoint | LocalPoint>(
  162. element: ExcalidrawEllipseElement,
  163. ): GeometricShape<Point> => {
  164. const { width, height, angle, x, y } = element;
  165. return {
  166. type: "ellipse",
  167. data: {
  168. center: pointFrom(x + width / 2, y + height / 2),
  169. angle,
  170. halfWidth: width / 2,
  171. halfHeight: height / 2,
  172. },
  173. };
  174. };
  175. export const getCurvePathOps = (shape: Drawable): Op[] => {
  176. // NOTE (mtolmacs): Temporary fix for extremely large elements
  177. if (!shape) {
  178. return [];
  179. }
  180. for (const set of shape.sets) {
  181. if (set.type === "path") {
  182. return set.ops;
  183. }
  184. }
  185. return shape.sets[0].ops;
  186. };
  187. // linear
  188. export const getCurveShape = <Point extends GlobalPoint | LocalPoint>(
  189. roughShape: Drawable,
  190. startingPoint: Point = pointFrom(0, 0),
  191. angleInRadian: Radians,
  192. center: Point,
  193. ): GeometricShape<Point> => {
  194. const transform = (p: Point): Point =>
  195. pointRotateRads(
  196. pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]),
  197. center,
  198. angleInRadian,
  199. );
  200. const ops = getCurvePathOps(roughShape);
  201. const polycurve: Polycurve<Point> = [];
  202. let p0 = pointFrom<Point>(0, 0);
  203. for (const op of ops) {
  204. if (op.op === "move") {
  205. const p = pointFromArray<Point>(op.data);
  206. invariant(p != null, "Ops data is not a point");
  207. p0 = transform(p);
  208. }
  209. if (op.op === "bcurveTo") {
  210. const p1 = transform(pointFrom<Point>(op.data[0], op.data[1]));
  211. const p2 = transform(pointFrom<Point>(op.data[2], op.data[3]));
  212. const p3 = transform(pointFrom<Point>(op.data[4], op.data[5]));
  213. polycurve.push(curve<Point>(p0, p1, p2, p3));
  214. p0 = p3;
  215. }
  216. }
  217. return {
  218. type: "polycurve",
  219. data: polycurve,
  220. };
  221. };
  222. const polylineFromPoints = <Point extends GlobalPoint | LocalPoint>(
  223. points: Point[],
  224. ): Polyline<Point> => {
  225. let previousPoint: Point = points[0];
  226. const polyline: LineSegment<Point>[] = [];
  227. for (let i = 1; i < points.length; i++) {
  228. const nextPoint = points[i];
  229. polyline.push(lineSegment<Point>(previousPoint, nextPoint));
  230. previousPoint = nextPoint;
  231. }
  232. return polyline;
  233. };
  234. export const getFreedrawShape = <Point extends GlobalPoint | LocalPoint>(
  235. element: ExcalidrawFreeDrawElement,
  236. center: Point,
  237. isClosed: boolean = false,
  238. ): GeometricShape<Point> => {
  239. const transform = (p: Point) =>
  240. pointRotateRads(
  241. pointFromVector(
  242. vectorAdd(vectorFromPoint(p), vector(element.x, element.y)),
  243. ),
  244. center,
  245. element.angle,
  246. );
  247. const polyline = polylineFromPoints(
  248. element.points.map((p) => transform(p as Point)),
  249. );
  250. return (
  251. isClosed
  252. ? {
  253. type: "polygon",
  254. data: polygonFromPoints(polyline.flat()),
  255. }
  256. : {
  257. type: "polyline",
  258. data: polyline,
  259. }
  260. ) as GeometricShape<Point>;
  261. };
  262. export const getClosedCurveShape = <Point extends GlobalPoint | LocalPoint>(
  263. element: ExcalidrawLinearElement,
  264. roughShape: Drawable,
  265. startingPoint: Point = pointFrom<Point>(0, 0),
  266. angleInRadian: Radians,
  267. center: Point,
  268. ): GeometricShape<Point> => {
  269. const transform = (p: Point) =>
  270. pointRotateRads(
  271. pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]),
  272. center,
  273. angleInRadian,
  274. );
  275. if (element.roundness === null) {
  276. return {
  277. type: "polygon",
  278. data: polygonFromPoints(
  279. element.points.map((p) => transform(p as Point)) as Point[],
  280. ),
  281. };
  282. }
  283. const ops = getCurvePathOps(roughShape);
  284. const points: Point[] = [];
  285. let odd = false;
  286. for (const operation of ops) {
  287. if (operation.op === "move") {
  288. odd = !odd;
  289. if (odd) {
  290. points.push(pointFrom(operation.data[0], operation.data[1]));
  291. }
  292. } else if (operation.op === "bcurveTo") {
  293. if (odd) {
  294. points.push(pointFrom(operation.data[0], operation.data[1]));
  295. points.push(pointFrom(operation.data[2], operation.data[3]));
  296. points.push(pointFrom(operation.data[4], operation.data[5]));
  297. }
  298. } else if (operation.op === "lineTo") {
  299. if (odd) {
  300. points.push(pointFrom(operation.data[0], operation.data[1]));
  301. }
  302. }
  303. }
  304. const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) =>
  305. transform(p as Point),
  306. ) as Point[];
  307. return {
  308. type: "polygon",
  309. data: polygonFromPoints<Point>(polygonPoints),
  310. };
  311. };
  312. /**
  313. * Determine intersection of a rectangular shaped element and a
  314. * line segment.
  315. *
  316. * @param element The rectangular element to test against
  317. * @param segment The segment intersecting the element
  318. * @param gap Optional value to inflate the shape before testing
  319. * @returns An array of intersections
  320. */
  321. // TODO: Replace with final rounded rectangle code
  322. export const segmentIntersectRectangleElement = <
  323. Point extends LocalPoint | GlobalPoint,
  324. >(
  325. element: ExcalidrawBindableElement,
  326. segment: LineSegment<Point>,
  327. gap: number = 0,
  328. ): Point[] => {
  329. const bounds = [
  330. element.x - gap,
  331. element.y - gap,
  332. element.x + element.width + gap,
  333. element.y + element.height + gap,
  334. ];
  335. const center = pointFrom(
  336. (bounds[0] + bounds[2]) / 2,
  337. (bounds[1] + bounds[3]) / 2,
  338. );
  339. return [
  340. lineSegment(
  341. pointRotateRads(pointFrom(bounds[0], bounds[1]), center, element.angle),
  342. pointRotateRads(pointFrom(bounds[2], bounds[1]), center, element.angle),
  343. ),
  344. lineSegment(
  345. pointRotateRads(pointFrom(bounds[2], bounds[1]), center, element.angle),
  346. pointRotateRads(pointFrom(bounds[2], bounds[3]), center, element.angle),
  347. ),
  348. lineSegment(
  349. pointRotateRads(pointFrom(bounds[2], bounds[3]), center, element.angle),
  350. pointRotateRads(pointFrom(bounds[0], bounds[3]), center, element.angle),
  351. ),
  352. lineSegment(
  353. pointRotateRads(pointFrom(bounds[0], bounds[3]), center, element.angle),
  354. pointRotateRads(pointFrom(bounds[0], bounds[1]), center, element.angle),
  355. ),
  356. ]
  357. .map((s) => segmentsIntersectAt(segment, s))
  358. .filter((i): i is Point => !!i);
  359. };
  360. const distanceToEllipse = <Point extends LocalPoint | GlobalPoint>(
  361. p: Point,
  362. ellipse: Ellipse<Point>,
  363. ) => {
  364. const { angle, halfWidth, halfHeight, center } = ellipse;
  365. const a = halfWidth;
  366. const b = halfHeight;
  367. const translatedPoint = vectorAdd(
  368. vectorFromPoint(p),
  369. vectorScale(vectorFromPoint(center), -1),
  370. );
  371. const [rotatedPointX, rotatedPointY] = pointRotateRads(
  372. pointFromVector(translatedPoint),
  373. pointFrom(0, 0),
  374. -angle as Radians,
  375. );
  376. const px = Math.abs(rotatedPointX);
  377. const py = Math.abs(rotatedPointY);
  378. let tx = 0.707;
  379. let ty = 0.707;
  380. for (let i = 0; i < 3; i++) {
  381. const x = a * tx;
  382. const y = b * ty;
  383. const ex = ((a * a - b * b) * tx ** 3) / a;
  384. const ey = ((b * b - a * a) * ty ** 3) / b;
  385. const rx = x - ex;
  386. const ry = y - ey;
  387. const qx = px - ex;
  388. const qy = py - ey;
  389. const r = Math.hypot(ry, rx);
  390. const q = Math.hypot(qy, qx);
  391. tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
  392. ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
  393. const t = Math.hypot(ty, tx);
  394. tx /= t;
  395. ty /= t;
  396. }
  397. const [minX, minY] = [
  398. a * tx * Math.sign(rotatedPointX),
  399. b * ty * Math.sign(rotatedPointY),
  400. ];
  401. return pointDistance(
  402. pointFrom(rotatedPointX, rotatedPointY),
  403. pointFrom(minX, minY),
  404. );
  405. };
  406. export const pointOnEllipse = <Point extends LocalPoint | GlobalPoint>(
  407. point: Point,
  408. ellipse: Ellipse<Point>,
  409. threshold = PRECISION,
  410. ) => {
  411. return distanceToEllipse(point, ellipse) <= threshold;
  412. };
  413. export const pointInEllipse = <Point extends LocalPoint | GlobalPoint>(
  414. p: Point,
  415. ellipse: Ellipse<Point>,
  416. ) => {
  417. const { center, angle, halfWidth, halfHeight } = ellipse;
  418. const translatedPoint = vectorAdd(
  419. vectorFromPoint(p),
  420. vectorScale(vectorFromPoint(center), -1),
  421. );
  422. const [rotatedPointX, rotatedPointY] = pointRotateRads(
  423. pointFromVector(translatedPoint),
  424. pointFrom(0, 0),
  425. -angle as Radians,
  426. );
  427. return (
  428. (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) +
  429. (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <=
  430. 1
  431. );
  432. };
  433. export const ellipseAxes = <Point extends LocalPoint | GlobalPoint>(
  434. ellipse: Ellipse<Point>,
  435. ) => {
  436. const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight;
  437. const majorAxis = widthGreaterThanHeight
  438. ? ellipse.halfWidth * 2
  439. : ellipse.halfHeight * 2;
  440. const minorAxis = widthGreaterThanHeight
  441. ? ellipse.halfHeight * 2
  442. : ellipse.halfWidth * 2;
  443. return {
  444. majorAxis,
  445. minorAxis,
  446. };
  447. };
  448. export const ellipseFocusToCenter = <Point extends LocalPoint | GlobalPoint>(
  449. ellipse: Ellipse<Point>,
  450. ) => {
  451. const { majorAxis, minorAxis } = ellipseAxes(ellipse);
  452. return Math.sqrt(majorAxis ** 2 - minorAxis ** 2);
  453. };
  454. export const ellipseExtremes = <Point extends LocalPoint | GlobalPoint>(
  455. ellipse: Ellipse<Point>,
  456. ) => {
  457. const { center, angle } = ellipse;
  458. const { majorAxis, minorAxis } = ellipseAxes(ellipse);
  459. const cos = Math.cos(angle);
  460. const sin = Math.sin(angle);
  461. const sqSum = majorAxis ** 2 + minorAxis ** 2;
  462. const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle);
  463. const yMax = Math.sqrt((sqSum - sqDiff) / 2);
  464. const xAtYMax =
  465. (yMax * sqSum * sin * cos) /
  466. (majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2);
  467. const xMax = Math.sqrt((sqSum + sqDiff) / 2);
  468. const yAtXMax =
  469. (xMax * sqSum * sin * cos) /
  470. (majorAxis ** 2 * cos ** 2 + minorAxis ** 2 * sin ** 2);
  471. const centerVector = vectorFromPoint(center);
  472. return [
  473. vectorAdd(vector(xAtYMax, yMax), centerVector),
  474. vectorAdd(vectorScale(vector(xAtYMax, yMax), -1), centerVector),
  475. vectorAdd(vector(xMax, yAtXMax), centerVector),
  476. vectorAdd(vector(xMax, yAtXMax), centerVector),
  477. ];
  478. };