collision.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. import { distanceBetweenPointAndSegment } from "../math";
  2. import { ExcalidrawElement } from "./types";
  3. import {
  4. getDiamondPoints,
  5. getElementAbsoluteCoords,
  6. getLinearElementAbsoluteBounds,
  7. } from "./bounds";
  8. import { Point } from "../types";
  9. import { Drawable, OpSet } from "roughjs/bin/core";
  10. import { AppState } from "../types";
  11. import { getShapeForElement } from "../renderer/renderElement";
  12. import { isLinearElement } from "./typeChecks";
  13. function isElementDraggableFromInside(
  14. element: ExcalidrawElement,
  15. appState: AppState,
  16. ): boolean {
  17. return (
  18. element.backgroundColor !== "transparent" ||
  19. appState.selectedElementIds[element.id]
  20. );
  21. }
  22. export function hitTest(
  23. element: ExcalidrawElement,
  24. appState: AppState,
  25. x: number,
  26. y: number,
  27. zoom: number,
  28. ): boolean {
  29. // For shapes that are composed of lines, we only enable point-selection when the distance
  30. // of the click is less than x pixels of any of the lines that the shape is composed of
  31. const lineThreshold = 10 / zoom;
  32. if (element.type === "ellipse") {
  33. // https://stackoverflow.com/a/46007540/232122
  34. const px = Math.abs(x - element.x - element.width / 2);
  35. const py = Math.abs(y - element.y - element.height / 2);
  36. let tx = 0.707;
  37. let ty = 0.707;
  38. const a = Math.abs(element.width) / 2;
  39. const b = Math.abs(element.height) / 2;
  40. [0, 1, 2, 3].forEach(x => {
  41. const xx = a * tx;
  42. const yy = b * ty;
  43. const ex = ((a * a - b * b) * tx ** 3) / a;
  44. const ey = ((b * b - a * a) * ty ** 3) / b;
  45. const rx = xx - ex;
  46. const ry = yy - ey;
  47. const qx = px - ex;
  48. const qy = py - ey;
  49. const r = Math.hypot(ry, rx);
  50. const q = Math.hypot(qy, qx);
  51. tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
  52. ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
  53. const t = Math.hypot(ty, tx);
  54. tx /= t;
  55. ty /= t;
  56. });
  57. if (isElementDraggableFromInside(element, appState)) {
  58. return (
  59. a * tx - (px - lineThreshold) >= 0 && b * ty - (py - lineThreshold) >= 0
  60. );
  61. }
  62. return Math.hypot(a * tx - px, b * ty - py) < lineThreshold;
  63. } else if (element.type === "rectangle") {
  64. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  65. if (isElementDraggableFromInside(element, appState)) {
  66. return (
  67. x > x1 - lineThreshold &&
  68. x < x2 + lineThreshold &&
  69. y > y1 - lineThreshold &&
  70. y < y2 + lineThreshold
  71. );
  72. }
  73. // (x1, y1) --A-- (x2, y1)
  74. // |D |B
  75. // (x1, y2) --C-- (x2, y2)
  76. return (
  77. distanceBetweenPointAndSegment(x, y, x1, y1, x2, y1) < lineThreshold || // A
  78. distanceBetweenPointAndSegment(x, y, x2, y1, x2, y2) < lineThreshold || // B
  79. distanceBetweenPointAndSegment(x, y, x2, y2, x1, y2) < lineThreshold || // C
  80. distanceBetweenPointAndSegment(x, y, x1, y2, x1, y1) < lineThreshold // D
  81. );
  82. } else if (element.type === "diamond") {
  83. x -= element.x;
  84. y -= element.y;
  85. let [
  86. topX,
  87. topY,
  88. rightX,
  89. rightY,
  90. bottomX,
  91. bottomY,
  92. leftX,
  93. leftY,
  94. ] = getDiamondPoints(element);
  95. if (isElementDraggableFromInside(element, appState)) {
  96. // TODO: remove this when we normalize coordinates globally
  97. if (topY > bottomY) {
  98. [bottomY, topY] = [topY, bottomY];
  99. }
  100. if (rightX < leftX) {
  101. [leftX, rightX] = [rightX, leftX];
  102. }
  103. topY -= lineThreshold;
  104. bottomY += lineThreshold;
  105. leftX -= lineThreshold;
  106. rightX += lineThreshold;
  107. // all deltas should be < 0. Delta > 0 indicates it's on the outside side
  108. // of the line.
  109. //
  110. // (topX, topY)
  111. // D / \ A
  112. // / \
  113. // (leftX, leftY) (rightX, rightY)
  114. // C \ / B
  115. // \ /
  116. // (bottomX, bottomY)
  117. //
  118. // https://stackoverflow.com/a/2752753/927631
  119. return (
  120. // delta from line D
  121. (leftX - topX) * (y - leftY) - (leftX - x) * (topY - leftY) <= 0 &&
  122. // delta from line A
  123. (topX - rightX) * (y - rightY) - (x - rightX) * (topY - rightY) <= 0 &&
  124. // delta from line B
  125. (rightX - bottomX) * (y - bottomY) -
  126. (x - bottomX) * (rightY - bottomY) <=
  127. 0 &&
  128. // delta from line C
  129. (bottomX - leftX) * (y - leftY) - (x - leftX) * (bottomY - leftY) <= 0
  130. );
  131. }
  132. return (
  133. distanceBetweenPointAndSegment(x, y, topX, topY, rightX, rightY) <
  134. lineThreshold ||
  135. distanceBetweenPointAndSegment(x, y, rightX, rightY, bottomX, bottomY) <
  136. lineThreshold ||
  137. distanceBetweenPointAndSegment(x, y, bottomX, bottomY, leftX, leftY) <
  138. lineThreshold ||
  139. distanceBetweenPointAndSegment(x, y, leftX, leftY, topX, topY) <
  140. lineThreshold
  141. );
  142. } else if (isLinearElement(element)) {
  143. if (!getShapeForElement(element)) {
  144. return false;
  145. }
  146. const shape = getShapeForElement(element) as Drawable[];
  147. const [x1, y1, x2, y2] = getLinearElementAbsoluteBounds(element);
  148. if (
  149. x < x1 - lineThreshold ||
  150. y < y1 - lineThreshold ||
  151. x > x2 + lineThreshold ||
  152. y > y2 + lineThreshold
  153. ) {
  154. return false;
  155. }
  156. const relX = x - element.x;
  157. const relY = y - element.y;
  158. // hit thest all "subshapes" of the linear element
  159. return shape.some(subshape =>
  160. hitTestRoughShape(subshape.sets, relX, relY, lineThreshold),
  161. );
  162. } else if (element.type === "text") {
  163. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  164. return x >= x1 && x <= x2 && y >= y1 && y <= y2;
  165. } else if (element.type === "selection") {
  166. console.warn("This should not happen, we need to investigate why it does.");
  167. return false;
  168. }
  169. throw new Error(`Unimplemented type ${element.type}`);
  170. }
  171. const pointInBezierEquation = (
  172. p0: Point,
  173. p1: Point,
  174. p2: Point,
  175. p3: Point,
  176. [mx, my]: Point,
  177. lineThreshold: number,
  178. ) => {
  179. // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
  180. const equation = (t: number, idx: number) =>
  181. Math.pow(1 - t, 3) * p3[idx] +
  182. 3 * t * Math.pow(1 - t, 2) * p2[idx] +
  183. 3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
  184. p0[idx] * Math.pow(t, 3);
  185. // go through t in increments of 0.01
  186. let t = 0;
  187. while (t <= 1.0) {
  188. const tx = equation(t, 0);
  189. const ty = equation(t, 1);
  190. const diff = Math.sqrt(Math.pow(tx - mx, 2) + Math.pow(ty - my, 2));
  191. if (diff < lineThreshold) {
  192. return true;
  193. }
  194. t += 0.01;
  195. }
  196. return false;
  197. };
  198. const hitTestRoughShape = (
  199. opSet: OpSet[],
  200. x: number,
  201. y: number,
  202. lineThreshold: number,
  203. ) => {
  204. // read operations from first opSet
  205. const ops = opSet[0].ops;
  206. // set start position as (0,0) just in case
  207. // move operation does not exist (unlikely but it is worth safekeeping it)
  208. let currentP: Point = [0, 0];
  209. return ops.some(({ op, data }, idx) => {
  210. // There are only four operation types:
  211. // move, bcurveTo, lineTo, and curveTo
  212. if (op === "move") {
  213. // change starting point
  214. currentP = (data as unknown) as Point;
  215. // move operation does not draw anything; so, it always
  216. // returns false
  217. } else if (op === "bcurveTo") {
  218. // create points from bezier curve
  219. // bezier curve stores data as a flattened array of three positions
  220. // [x1, y1, x2, y2, x3, y3]
  221. const p1 = [data[0], data[1]] as Point;
  222. const p2 = [data[2], data[3]] as Point;
  223. const p3 = [data[4], data[5]] as Point;
  224. const p0 = currentP;
  225. currentP = p3;
  226. // check if points are on the curve
  227. // cubic bezier curves require four parameters
  228. // the first parameter is the last stored position (p0)
  229. const retVal = pointInBezierEquation(
  230. p0,
  231. p1,
  232. p2,
  233. p3,
  234. [x, y],
  235. lineThreshold,
  236. );
  237. // set end point of bezier curve as the new starting point for
  238. // upcoming operations as each operation is based on the last drawn
  239. // position of the previous operation
  240. return retVal;
  241. } else if (op === "lineTo") {
  242. // TODO: Implement this
  243. } else if (op === "qcurveTo") {
  244. // TODO: Implement this
  245. }
  246. return false;
  247. });
  248. };