shape.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  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. pointFrom,
  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. ) & {
  97. isClosed?: boolean;
  98. };
  99. type RectangularElement =
  100. | ExcalidrawRectangleElement
  101. | ExcalidrawDiamondElement
  102. | ExcalidrawFrameLikeElement
  103. | ExcalidrawEmbeddableElement
  104. | ExcalidrawImageElement
  105. | ExcalidrawIframeElement
  106. | ExcalidrawTextElement
  107. | ExcalidrawSelectionElement;
  108. // polygon
  109. export const getPolygonShape = <Point extends GlobalPoint | LocalPoint>(
  110. element: RectangularElement,
  111. ): GeometricShape<Point> => {
  112. const { angle, width, height, x, y } = element;
  113. const cx = x + width / 2;
  114. const cy = y + height / 2;
  115. const center: Point = pointFrom(cx, cy);
  116. let data: Polygon<Point>;
  117. if (element.type === "diamond") {
  118. data = polygon(
  119. pointRotateRads(pointFrom(cx, y), center, angle),
  120. pointRotateRads(pointFrom(x + width, cy), center, angle),
  121. pointRotateRads(pointFrom(cx, y + height), center, angle),
  122. pointRotateRads(pointFrom(x, cy), center, angle),
  123. );
  124. } else {
  125. data = polygon(
  126. pointRotateRads(pointFrom(x, y), center, angle),
  127. pointRotateRads(pointFrom(x + width, y), center, angle),
  128. pointRotateRads(pointFrom(x + width, y + height), center, angle),
  129. pointRotateRads(pointFrom(x, y + height), center, angle),
  130. );
  131. }
  132. return {
  133. type: "polygon",
  134. data,
  135. };
  136. };
  137. // return the selection box for an element, possibly rotated as well
  138. export const getSelectionBoxShape = <Point extends GlobalPoint | LocalPoint>(
  139. element: ExcalidrawElement,
  140. elementsMap: ElementsMap,
  141. padding = 10,
  142. ) => {
  143. let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
  144. element,
  145. elementsMap,
  146. true,
  147. );
  148. x1 -= padding;
  149. x2 += padding;
  150. y1 -= padding;
  151. y2 += padding;
  152. //const angleInDegrees = angleToDegrees(element.angle);
  153. const center = pointFrom(cx, cy);
  154. const topLeft = pointRotateRads(pointFrom(x1, y1), center, element.angle);
  155. const topRight = pointRotateRads(pointFrom(x2, y1), center, element.angle);
  156. const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, element.angle);
  157. const bottomRight = pointRotateRads(pointFrom(x2, y2), center, element.angle);
  158. return {
  159. type: "polygon",
  160. data: [topLeft, topRight, bottomRight, bottomLeft],
  161. } as GeometricShape<Point>;
  162. };
  163. // ellipse
  164. export const getEllipseShape = <Point extends GlobalPoint | LocalPoint>(
  165. element: ExcalidrawEllipseElement,
  166. ): GeometricShape<Point> => {
  167. const { width, height, angle, x, y } = element;
  168. return {
  169. type: "ellipse",
  170. data: {
  171. center: pointFrom(x + width / 2, y + height / 2),
  172. angle,
  173. halfWidth: width / 2,
  174. halfHeight: height / 2,
  175. },
  176. };
  177. };
  178. export const getCurvePathOps = (shape: Drawable): Op[] => {
  179. for (const set of shape.sets) {
  180. if (set.type === "path") {
  181. return set.ops;
  182. }
  183. }
  184. return shape.sets[0].ops;
  185. };
  186. // linear
  187. export const getCurveShape = <Point extends GlobalPoint | LocalPoint>(
  188. roughShape: Drawable,
  189. angleInRadian: Radians,
  190. center: Point,
  191. startingPoint: Point = pointFrom(0, 0),
  192. ): GeometricShape<Point> => {
  193. const transform = (p: Point): Point =>
  194. pointRotateRads(
  195. pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]),
  196. center,
  197. angleInRadian,
  198. );
  199. const ops = getCurvePathOps(roughShape);
  200. const polycurve: Polycurve<Point> = [];
  201. let p0 = pointFrom<Point>(0, 0);
  202. for (const op of ops) {
  203. if (op.op === "move") {
  204. const p = pointFromArray<Point>(op.data);
  205. invariant(p != null, "Ops data is not a point");
  206. p0 = transform(p);
  207. }
  208. if (op.op === "bcurveTo") {
  209. const p1 = transform(pointFrom<Point>(op.data[0], op.data[1]));
  210. const p2 = transform(pointFrom<Point>(op.data[2], op.data[3]));
  211. const p3 = transform(pointFrom<Point>(op.data[4], op.data[5]));
  212. polycurve.push(curve<Point>(p0, p1, p2, p3));
  213. p0 = p3;
  214. }
  215. }
  216. return {
  217. type: "polycurve",
  218. data: polycurve,
  219. };
  220. };
  221. const polylineFromPoints = <Point extends GlobalPoint | LocalPoint>(
  222. points: Point[],
  223. ): Polyline<Point> => {
  224. let previousPoint: Point = points[0];
  225. const polyline: LineSegment<Point>[] = [];
  226. for (let i = 1; i < points.length; i++) {
  227. const nextPoint = points[i];
  228. polyline.push(lineSegment<Point>(previousPoint, nextPoint));
  229. previousPoint = nextPoint;
  230. }
  231. return polyline;
  232. };
  233. export const getFreedrawShape = <Point extends GlobalPoint | LocalPoint>(
  234. element: ExcalidrawFreeDrawElement,
  235. center: Point,
  236. isClosed: boolean = false,
  237. ): GeometricShape<Point> => {
  238. const transform = (p: Point) =>
  239. pointRotateRads(
  240. pointFromVector(
  241. vectorAdd(vectorFromPoint(p), vector(element.x, element.y)),
  242. ),
  243. center,
  244. element.angle,
  245. );
  246. const polyline = polylineFromPoints(
  247. element.points.map((p) => transform(p as Point)),
  248. );
  249. return (
  250. isClosed
  251. ? {
  252. type: "polygon",
  253. data: polygonFromPoints(polyline.flat()),
  254. }
  255. : {
  256. type: "polyline",
  257. data: polyline,
  258. }
  259. ) as GeometricShape<Point>;
  260. };
  261. export const getPointsOnRoughCurve = <Point extends GlobalPoint | LocalPoint>(
  262. roughCurve: Drawable,
  263. ) => {
  264. const ops = getCurvePathOps(roughCurve);
  265. const points: Point[] = [];
  266. let odd = false;
  267. for (const operation of ops) {
  268. if (operation.op === "move") {
  269. odd = !odd;
  270. if (odd) {
  271. points.push(pointFrom(operation.data[0], operation.data[1]));
  272. }
  273. } else if (operation.op === "bcurveTo") {
  274. if (odd) {
  275. points.push(pointFrom(operation.data[0], operation.data[1]));
  276. points.push(pointFrom(operation.data[2], operation.data[3]));
  277. points.push(pointFrom(operation.data[4], operation.data[5]));
  278. }
  279. } else if (operation.op === "lineTo") {
  280. if (odd) {
  281. points.push(pointFrom(operation.data[0], operation.data[1]));
  282. }
  283. }
  284. }
  285. return points;
  286. };
  287. export const getClosedCurveShape = <Point extends GlobalPoint | LocalPoint>(
  288. element: ExcalidrawLinearElement,
  289. roughShape: Drawable,
  290. startingPoint: Point = pointFrom<Point>(0, 0),
  291. angleInRadian: Radians,
  292. center: Point,
  293. ): GeometricShape<Point> => {
  294. const transform = (p: Point) =>
  295. pointRotateRads(
  296. pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]),
  297. center,
  298. angleInRadian,
  299. );
  300. if (element.roundness === null) {
  301. return {
  302. type: "polygon",
  303. data: polygonFromPoints(
  304. element.points.map((p) => transform(p as Point)) as Point[],
  305. ),
  306. };
  307. }
  308. const polygonPoints = pointsOnBezierCurves(
  309. getPointsOnRoughCurve(roughShape),
  310. 10,
  311. 5,
  312. ) as Point[];
  313. return {
  314. type: "polygon",
  315. data: polygonFromPoints<Point>(polygonPoints),
  316. };
  317. };
  318. /**
  319. * Determine intersection of a rectangular shaped element and a
  320. * line segment.
  321. *
  322. * @param element The rectangular element to test against
  323. * @param segment The segment intersecting the element
  324. * @param gap Optional value to inflate the shape before testing
  325. * @returns An array of intersections
  326. */
  327. // TODO: Replace with final rounded rectangle code
  328. export const segmentIntersectRectangleElement = <
  329. Point extends LocalPoint | GlobalPoint,
  330. >(
  331. element: ExcalidrawBindableElement,
  332. segment: LineSegment<Point>,
  333. gap: number = 0,
  334. ): Point[] => {
  335. const bounds = [
  336. element.x - gap,
  337. element.y - gap,
  338. element.x + element.width + gap,
  339. element.y + element.height + gap,
  340. ];
  341. const center = pointFrom(
  342. (bounds[0] + bounds[2]) / 2,
  343. (bounds[1] + bounds[3]) / 2,
  344. );
  345. return [
  346. lineSegment(
  347. pointRotateRads(pointFrom(bounds[0], bounds[1]), center, element.angle),
  348. pointRotateRads(pointFrom(bounds[2], bounds[1]), center, element.angle),
  349. ),
  350. lineSegment(
  351. pointRotateRads(pointFrom(bounds[2], bounds[1]), center, element.angle),
  352. pointRotateRads(pointFrom(bounds[2], bounds[3]), center, element.angle),
  353. ),
  354. lineSegment(
  355. pointRotateRads(pointFrom(bounds[2], bounds[3]), center, element.angle),
  356. pointRotateRads(pointFrom(bounds[0], bounds[3]), center, element.angle),
  357. ),
  358. lineSegment(
  359. pointRotateRads(pointFrom(bounds[0], bounds[3]), center, element.angle),
  360. pointRotateRads(pointFrom(bounds[0], bounds[1]), center, element.angle),
  361. ),
  362. ]
  363. .map((s) => segmentsIntersectAt(segment, s))
  364. .filter((i): i is Point => !!i);
  365. };
  366. const distanceToEllipse = <Point extends LocalPoint | GlobalPoint>(
  367. p: Point,
  368. ellipse: Ellipse<Point>,
  369. ) => {
  370. const { angle, halfWidth, halfHeight, center } = ellipse;
  371. const a = halfWidth;
  372. const b = halfHeight;
  373. const translatedPoint = vectorAdd(
  374. vectorFromPoint(p),
  375. vectorScale(vectorFromPoint(center), -1),
  376. );
  377. const [rotatedPointX, rotatedPointY] = pointRotateRads(
  378. pointFromVector(translatedPoint),
  379. pointFrom(0, 0),
  380. -angle as Radians,
  381. );
  382. const px = Math.abs(rotatedPointX);
  383. const py = Math.abs(rotatedPointY);
  384. let tx = 0.707;
  385. let ty = 0.707;
  386. for (let i = 0; i < 3; i++) {
  387. const x = a * tx;
  388. const y = b * ty;
  389. const ex = ((a * a - b * b) * tx ** 3) / a;
  390. const ey = ((b * b - a * a) * ty ** 3) / b;
  391. const rx = x - ex;
  392. const ry = y - ey;
  393. const qx = px - ex;
  394. const qy = py - ey;
  395. const r = Math.hypot(ry, rx);
  396. const q = Math.hypot(qy, qx);
  397. tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
  398. ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
  399. const t = Math.hypot(ty, tx);
  400. tx /= t;
  401. ty /= t;
  402. }
  403. const [minX, minY] = [
  404. a * tx * Math.sign(rotatedPointX),
  405. b * ty * Math.sign(rotatedPointY),
  406. ];
  407. return pointDistance(
  408. pointFrom(rotatedPointX, rotatedPointY),
  409. pointFrom(minX, minY),
  410. );
  411. };
  412. export const pointOnEllipse = <Point extends LocalPoint | GlobalPoint>(
  413. point: Point,
  414. ellipse: Ellipse<Point>,
  415. threshold = PRECISION,
  416. ) => {
  417. return distanceToEllipse(point, ellipse) <= threshold;
  418. };
  419. export const pointInEllipse = <Point extends LocalPoint | GlobalPoint>(
  420. p: Point,
  421. ellipse: Ellipse<Point>,
  422. ) => {
  423. const { center, angle, halfWidth, halfHeight } = ellipse;
  424. const translatedPoint = vectorAdd(
  425. vectorFromPoint(p),
  426. vectorScale(vectorFromPoint(center), -1),
  427. );
  428. const [rotatedPointX, rotatedPointY] = pointRotateRads(
  429. pointFromVector(translatedPoint),
  430. pointFrom(0, 0),
  431. -angle as Radians,
  432. );
  433. return (
  434. (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) +
  435. (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <=
  436. 1
  437. );
  438. };
  439. export const ellipseAxes = <Point extends LocalPoint | GlobalPoint>(
  440. ellipse: Ellipse<Point>,
  441. ) => {
  442. const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight;
  443. const majorAxis = widthGreaterThanHeight
  444. ? ellipse.halfWidth * 2
  445. : ellipse.halfHeight * 2;
  446. const minorAxis = widthGreaterThanHeight
  447. ? ellipse.halfHeight * 2
  448. : ellipse.halfWidth * 2;
  449. return {
  450. majorAxis,
  451. minorAxis,
  452. };
  453. };
  454. export const ellipseFocusToCenter = <Point extends LocalPoint | GlobalPoint>(
  455. ellipse: Ellipse<Point>,
  456. ) => {
  457. const { majorAxis, minorAxis } = ellipseAxes(ellipse);
  458. return Math.sqrt(majorAxis ** 2 - minorAxis ** 2);
  459. };
  460. export const ellipseExtremes = <Point extends LocalPoint | GlobalPoint>(
  461. ellipse: Ellipse<Point>,
  462. ) => {
  463. const { center, angle } = ellipse;
  464. const { majorAxis, minorAxis } = ellipseAxes(ellipse);
  465. const cos = Math.cos(angle);
  466. const sin = Math.sin(angle);
  467. const sqSum = majorAxis ** 2 + minorAxis ** 2;
  468. const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle);
  469. const yMax = Math.sqrt((sqSum - sqDiff) / 2);
  470. const xAtYMax =
  471. (yMax * sqSum * sin * cos) /
  472. (majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2);
  473. const xMax = Math.sqrt((sqSum + sqDiff) / 2);
  474. const yAtXMax =
  475. (xMax * sqSum * sin * cos) /
  476. (majorAxis ** 2 * cos ** 2 + minorAxis ** 2 * sin ** 2);
  477. const centerVector = vectorFromPoint(center);
  478. return [
  479. vectorAdd(vector(xAtYMax, yMax), centerVector),
  480. vectorAdd(vectorScale(vector(xAtYMax, yMax), -1), centerVector),
  481. vectorAdd(vector(xMax, yAtXMax), centerVector),
  482. vectorAdd(vector(xMax, yAtXMax), centerVector),
  483. ];
  484. };