shape.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  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 type { Curve, LineSegment, Polygon, Radians } from "../../math";
  15. import {
  16. curve,
  17. lineSegment,
  18. point,
  19. pointDistance,
  20. pointFromArray,
  21. pointFromVector,
  22. pointRotateRads,
  23. polygon,
  24. polygonFromPoints,
  25. PRECISION,
  26. segmentsIntersectAt,
  27. vector,
  28. vectorAdd,
  29. vectorFromPoint,
  30. vectorScale,
  31. type GlobalPoint,
  32. type LocalPoint,
  33. } from "../../math";
  34. import { getElementAbsoluteCoords } from "../../excalidraw/element";
  35. import type {
  36. ElementsMap,
  37. ExcalidrawBindableElement,
  38. ExcalidrawDiamondElement,
  39. ExcalidrawElement,
  40. ExcalidrawEllipseElement,
  41. ExcalidrawEmbeddableElement,
  42. ExcalidrawFrameLikeElement,
  43. ExcalidrawFreeDrawElement,
  44. ExcalidrawIframeElement,
  45. ExcalidrawImageElement,
  46. ExcalidrawLinearElement,
  47. ExcalidrawRectangleElement,
  48. ExcalidrawSelectionElement,
  49. ExcalidrawTextElement,
  50. } from "../../excalidraw/element/types";
  51. import { pointsOnBezierCurves } from "points-on-curve";
  52. import type { Drawable, Op } from "roughjs/bin/core";
  53. import { invariant } from "../../excalidraw/utils";
  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 = point(cx, cy);
  113. let data: Polygon<Point>;
  114. if (element.type === "diamond") {
  115. data = polygon(
  116. pointRotateRads(point(cx, y), center, angle),
  117. pointRotateRads(point(x + width, cy), center, angle),
  118. pointRotateRads(point(cx, y + height), center, angle),
  119. pointRotateRads(point(x, cy), center, angle),
  120. );
  121. } else {
  122. data = polygon(
  123. pointRotateRads(point(x, y), center, angle),
  124. pointRotateRads(point(x + width, y), center, angle),
  125. pointRotateRads(point(x + width, y + height), center, angle),
  126. pointRotateRads(point(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 = point(cx, cy);
  151. const topLeft = pointRotateRads(point(x1, y1), center, element.angle);
  152. const topRight = pointRotateRads(point(x2, y1), center, element.angle);
  153. const bottomLeft = pointRotateRads(point(x1, y2), center, element.angle);
  154. const bottomRight = pointRotateRads(point(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: point(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. for (const set of shape.sets) {
  177. if (set.type === "path") {
  178. return set.ops;
  179. }
  180. }
  181. return shape.sets[0].ops;
  182. };
  183. // linear
  184. export const getCurveShape = <Point extends GlobalPoint | LocalPoint>(
  185. roughShape: Drawable,
  186. startingPoint: Point = point(0, 0),
  187. angleInRadian: Radians,
  188. center: Point,
  189. ): GeometricShape<Point> => {
  190. const transform = (p: Point): Point =>
  191. pointRotateRads(
  192. point(p[0] + startingPoint[0], p[1] + startingPoint[1]),
  193. center,
  194. angleInRadian,
  195. );
  196. const ops = getCurvePathOps(roughShape);
  197. const polycurve: Polycurve<Point> = [];
  198. let p0 = point<Point>(0, 0);
  199. for (const op of ops) {
  200. if (op.op === "move") {
  201. const p = pointFromArray<Point>(op.data);
  202. invariant(p != null, "Ops data is not a point");
  203. p0 = transform(p);
  204. }
  205. if (op.op === "bcurveTo") {
  206. const p1 = transform(point<Point>(op.data[0], op.data[1]));
  207. const p2 = transform(point<Point>(op.data[2], op.data[3]));
  208. const p3 = transform(point<Point>(op.data[4], op.data[5]));
  209. polycurve.push(curve<Point>(p0, p1, p2, p3));
  210. p0 = p3;
  211. }
  212. }
  213. return {
  214. type: "polycurve",
  215. data: polycurve,
  216. };
  217. };
  218. const polylineFromPoints = <Point extends GlobalPoint | LocalPoint>(
  219. points: Point[],
  220. ): Polyline<Point> => {
  221. let previousPoint: Point = points[0];
  222. const polyline: LineSegment<Point>[] = [];
  223. for (let i = 1; i < points.length; i++) {
  224. const nextPoint = points[i];
  225. polyline.push(lineSegment<Point>(previousPoint, nextPoint));
  226. previousPoint = nextPoint;
  227. }
  228. return polyline;
  229. };
  230. export const getFreedrawShape = <Point extends GlobalPoint | LocalPoint>(
  231. element: ExcalidrawFreeDrawElement,
  232. center: Point,
  233. isClosed: boolean = false,
  234. ): GeometricShape<Point> => {
  235. const transform = (p: Point) =>
  236. pointRotateRads(
  237. pointFromVector(
  238. vectorAdd(vectorFromPoint(p), vector(element.x, element.y)),
  239. ),
  240. center,
  241. element.angle,
  242. );
  243. const polyline = polylineFromPoints(
  244. element.points.map((p) => transform(p as Point)),
  245. );
  246. return (
  247. isClosed
  248. ? {
  249. type: "polygon",
  250. data: polygonFromPoints(polyline.flat()),
  251. }
  252. : {
  253. type: "polyline",
  254. data: polyline,
  255. }
  256. ) as GeometricShape<Point>;
  257. };
  258. export const getClosedCurveShape = <Point extends GlobalPoint | LocalPoint>(
  259. element: ExcalidrawLinearElement,
  260. roughShape: Drawable,
  261. startingPoint: Point = point<Point>(0, 0),
  262. angleInRadian: Radians,
  263. center: Point,
  264. ): GeometricShape<Point> => {
  265. const transform = (p: Point) =>
  266. pointRotateRads(
  267. point(p[0] + startingPoint[0], p[1] + startingPoint[1]),
  268. center,
  269. angleInRadian,
  270. );
  271. if (element.roundness === null) {
  272. return {
  273. type: "polygon",
  274. data: polygonFromPoints(
  275. element.points.map((p) => transform(p as Point)) as Point[],
  276. ),
  277. };
  278. }
  279. const ops = getCurvePathOps(roughShape);
  280. const points: Point[] = [];
  281. let odd = false;
  282. for (const operation of ops) {
  283. if (operation.op === "move") {
  284. odd = !odd;
  285. if (odd) {
  286. points.push(point(operation.data[0], operation.data[1]));
  287. }
  288. } else if (operation.op === "bcurveTo") {
  289. if (odd) {
  290. points.push(point(operation.data[0], operation.data[1]));
  291. points.push(point(operation.data[2], operation.data[3]));
  292. points.push(point(operation.data[4], operation.data[5]));
  293. }
  294. } else if (operation.op === "lineTo") {
  295. if (odd) {
  296. points.push(point(operation.data[0], operation.data[1]));
  297. }
  298. }
  299. }
  300. const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) =>
  301. transform(p as Point),
  302. ) as Point[];
  303. return {
  304. type: "polygon",
  305. data: polygonFromPoints<Point>(polygonPoints),
  306. };
  307. };
  308. /**
  309. * Determine intersection of a rectangular shaped element and a
  310. * line segment.
  311. *
  312. * @param element The rectangular element to test against
  313. * @param segment The segment intersecting the element
  314. * @param gap Optional value to inflate the shape before testing
  315. * @returns An array of intersections
  316. */
  317. // TODO: Replace with final rounded rectangle code
  318. export const segmentIntersectRectangleElement = <
  319. Point extends LocalPoint | GlobalPoint,
  320. >(
  321. element: ExcalidrawBindableElement,
  322. segment: LineSegment<Point>,
  323. gap: number = 0,
  324. ): Point[] => {
  325. const bounds = [
  326. element.x - gap,
  327. element.y - gap,
  328. element.x + element.width + gap,
  329. element.y + element.height + gap,
  330. ];
  331. const center = point(
  332. (bounds[0] + bounds[2]) / 2,
  333. (bounds[1] + bounds[3]) / 2,
  334. );
  335. return [
  336. lineSegment(
  337. pointRotateRads(point(bounds[0], bounds[1]), center, element.angle),
  338. pointRotateRads(point(bounds[2], bounds[1]), center, element.angle),
  339. ),
  340. lineSegment(
  341. pointRotateRads(point(bounds[2], bounds[1]), center, element.angle),
  342. pointRotateRads(point(bounds[2], bounds[3]), center, element.angle),
  343. ),
  344. lineSegment(
  345. pointRotateRads(point(bounds[2], bounds[3]), center, element.angle),
  346. pointRotateRads(point(bounds[0], bounds[3]), center, element.angle),
  347. ),
  348. lineSegment(
  349. pointRotateRads(point(bounds[0], bounds[3]), center, element.angle),
  350. pointRotateRads(point(bounds[0], bounds[1]), center, element.angle),
  351. ),
  352. ]
  353. .map((s) => segmentsIntersectAt(segment, s))
  354. .filter((i): i is Point => !!i);
  355. };
  356. const distanceToEllipse = <Point extends LocalPoint | GlobalPoint>(
  357. p: Point,
  358. ellipse: Ellipse<Point>,
  359. ) => {
  360. const { angle, halfWidth, halfHeight, center } = ellipse;
  361. const a = halfWidth;
  362. const b = halfHeight;
  363. const translatedPoint = vectorAdd(
  364. vectorFromPoint(p),
  365. vectorScale(vectorFromPoint(center), -1),
  366. );
  367. const [rotatedPointX, rotatedPointY] = pointRotateRads(
  368. pointFromVector(translatedPoint),
  369. point(0, 0),
  370. -angle as Radians,
  371. );
  372. const px = Math.abs(rotatedPointX);
  373. const py = Math.abs(rotatedPointY);
  374. let tx = 0.707;
  375. let ty = 0.707;
  376. for (let i = 0; i < 3; i++) {
  377. const x = a * tx;
  378. const y = b * ty;
  379. const ex = ((a * a - b * b) * tx ** 3) / a;
  380. const ey = ((b * b - a * a) * ty ** 3) / b;
  381. const rx = x - ex;
  382. const ry = y - ey;
  383. const qx = px - ex;
  384. const qy = py - ey;
  385. const r = Math.hypot(ry, rx);
  386. const q = Math.hypot(qy, qx);
  387. tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
  388. ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
  389. const t = Math.hypot(ty, tx);
  390. tx /= t;
  391. ty /= t;
  392. }
  393. const [minX, minY] = [
  394. a * tx * Math.sign(rotatedPointX),
  395. b * ty * Math.sign(rotatedPointY),
  396. ];
  397. return pointDistance(point(rotatedPointX, rotatedPointY), point(minX, minY));
  398. };
  399. export const pointOnEllipse = <Point extends LocalPoint | GlobalPoint>(
  400. point: Point,
  401. ellipse: Ellipse<Point>,
  402. threshold = PRECISION,
  403. ) => {
  404. return distanceToEllipse(point, ellipse) <= threshold;
  405. };
  406. export const pointInEllipse = <Point extends LocalPoint | GlobalPoint>(
  407. p: Point,
  408. ellipse: Ellipse<Point>,
  409. ) => {
  410. const { center, angle, halfWidth, halfHeight } = ellipse;
  411. const translatedPoint = vectorAdd(
  412. vectorFromPoint(p),
  413. vectorScale(vectorFromPoint(center), -1),
  414. );
  415. const [rotatedPointX, rotatedPointY] = pointRotateRads(
  416. pointFromVector(translatedPoint),
  417. point(0, 0),
  418. -angle as Radians,
  419. );
  420. return (
  421. (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) +
  422. (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <=
  423. 1
  424. );
  425. };
  426. export const ellipseAxes = <Point extends LocalPoint | GlobalPoint>(
  427. ellipse: Ellipse<Point>,
  428. ) => {
  429. const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight;
  430. const majorAxis = widthGreaterThanHeight
  431. ? ellipse.halfWidth * 2
  432. : ellipse.halfHeight * 2;
  433. const minorAxis = widthGreaterThanHeight
  434. ? ellipse.halfHeight * 2
  435. : ellipse.halfWidth * 2;
  436. return {
  437. majorAxis,
  438. minorAxis,
  439. };
  440. };
  441. export const ellipseFocusToCenter = <Point extends LocalPoint | GlobalPoint>(
  442. ellipse: Ellipse<Point>,
  443. ) => {
  444. const { majorAxis, minorAxis } = ellipseAxes(ellipse);
  445. return Math.sqrt(majorAxis ** 2 - minorAxis ** 2);
  446. };
  447. export const ellipseExtremes = <Point extends LocalPoint | GlobalPoint>(
  448. ellipse: Ellipse<Point>,
  449. ) => {
  450. const { center, angle } = ellipse;
  451. const { majorAxis, minorAxis } = ellipseAxes(ellipse);
  452. const cos = Math.cos(angle);
  453. const sin = Math.sin(angle);
  454. const sqSum = majorAxis ** 2 + minorAxis ** 2;
  455. const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle);
  456. const yMax = Math.sqrt((sqSum - sqDiff) / 2);
  457. const xAtYMax =
  458. (yMax * sqSum * sin * cos) /
  459. (majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2);
  460. const xMax = Math.sqrt((sqSum + sqDiff) / 2);
  461. const yAtXMax =
  462. (xMax * sqSum * sin * cos) /
  463. (majorAxis ** 2 * cos ** 2 + minorAxis ** 2 * sin ** 2);
  464. const centerVector = vectorFromPoint(center);
  465. return [
  466. vectorAdd(vector(xAtYMax, yMax), centerVector),
  467. vectorAdd(vectorScale(vector(xAtYMax, yMax), -1), centerVector),
  468. vectorAdd(vector(xMax, yAtXMax), centerVector),
  469. vectorAdd(vector(xMax, yAtXMax), centerVector),
  470. ];
  471. };