collision.ts 9.3 KB

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