Precise hit testing

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs 2025-05-06 17:29:45 +02:00
parent 41a7613dff
commit 0c5d3850d0
No known key found for this signature in database
11 changed files with 145 additions and 198 deletions

View File

@ -27,8 +27,6 @@ import {
PRECISION, PRECISION,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isPointOnShape } from "@excalidraw/utils/collision";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
@ -41,7 +39,7 @@ import {
doBoundsIntersect, doBoundsIntersect,
} from "./bounds"; } from "./bounds";
import { intersectElementWithLineSegment } from "./collision"; import { intersectElementWithLineSegment } from "./collision";
import { distanceToBindableElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
headingForPointFromElement, headingForPointFromElement,
headingIsHorizontal, headingIsHorizontal,
@ -63,7 +61,7 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes"; import { aabbForElement } from "./shapes";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
@ -704,7 +702,7 @@ const calculateFocusAndGap = (
return { return {
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), gap: Math.max(1, distanceToElement(hoveredElement, edgePoint)),
}; };
}; };
@ -898,7 +896,7 @@ const getDistanceForBinding = (
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
) => { ) => {
const distance = distanceToBindableElement(bindableElement, point); const distance = distanceToElement(bindableElement, point);
const bindDistance = maxBindingGap( const bindDistance = maxBindingGap(
bindableElement, bindableElement,
bindableElement.width, bindableElement.width,
@ -1548,14 +1546,22 @@ export const bindingBorderTest = (
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
fullShape?: boolean, fullShape?: boolean,
): boolean => { ): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const threshold = maxBindingGap(element, element.width, element.height, zoom); const threshold = maxBindingGap(element, element.width, element.height, zoom);
const shouldTestInside =
const shape = getElementShape(element, elementsMap); // disable fullshape snapping for frame elements so we
return ( // can bind to frame children
isPointOnShape(pointFrom(x, y), shape, threshold) || (fullShape || !isBindingFallthroughEnabled(element)) &&
(fullShape === true && !isFrameLikeElement(element);
pointInsideBounds(pointFrom(x, y), aabbForElement(element))) const intersections = intersectElementWithLineSegment(
element,
lineSegment(elementCenterPoint(element), p),
); );
const distance = distanceToElement(element, p);
return shouldTestInside
? intersections.length === 0 || distance <= threshold
: intersections.length > 0 && distance <= threshold;
}; };
export const maxBindingGap = ( export const maxBindingGap = (

View File

@ -16,24 +16,18 @@ import {
} from "@excalidraw/math/ellipse"; } from "@excalidraw/math/ellipse";
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision"; import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
import type { import type { GlobalPoint, LineSegment, Radians } from "@excalidraw/math";
GlobalPoint,
LineSegment,
LocalPoint,
Polygon,
Radians,
} from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { getBoundTextShape, isPathALoop } from "./shapes"; import { isPathALoop } from "./shapes";
import { getElementBounds } from "./bounds"; import { getElementBounds } from "./bounds";
import { import {
hasBoundTextElement, hasBoundTextElement,
isIframeLikeElement, isIframeLikeElement,
isImageElement, isImageElement,
isLinearElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { import {
@ -41,12 +35,15 @@ import {
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
import { getBoundTextElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawRectangleElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
@ -72,45 +69,40 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
return isDraggableFromInside || isImageElement(element); return isDraggableFromInside || isImageElement(element);
}; };
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = { export type HitTestArgs = {
x: number; point: GlobalPoint;
y: number;
element: ExcalidrawElement; element: ExcalidrawElement;
shape: GeometricShape<Point>;
threshold?: number; threshold?: number;
frameNameBound?: FrameNameBounds | null; frameNameBound?: FrameNameBounds | null;
}; };
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({ export const hitElementItself = ({
x, point,
y,
element, element,
shape,
threshold = 10, threshold = 10,
frameNameBound = null, frameNameBound = null,
}: HitTestArgs<Point>) => { }: HitTestArgs) => {
let hit = shouldTestInside(element) let hit = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape ? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders" // we would need `onShape` as well to include the "borders"
isPointInShape(pointFrom(x, y), shape) || isPointInShape(point, element) ||
isPointOnShape(pointFrom(x, y), shape, threshold) isPointOnShape(point, element, threshold)
: isPointOnShape(pointFrom(x, y), shape, threshold); : isPointOnShape(point, element, threshold);
// hit test against a frame's name // hit test against a frame's name
if (!hit && frameNameBound) { if (!hit && frameNameBound) {
hit = isPointInShape(pointFrom(x, y), { const x1 = frameNameBound.x - threshold;
type: "polygon", const y1 = frameNameBound.y - threshold;
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement) const x2 = frameNameBound.x + frameNameBound.width + threshold;
.data as Polygon<Point>, const y2 = frameNameBound.y + frameNameBound.height + threshold;
}); hit = isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
} }
return hit; return hit;
}; };
export const hitElementBoundingBox = ( export const hitElementBoundingBox = (
x: number, point: GlobalPoint,
y: number,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
tolerance = 0, tolerance = 0,
@ -120,37 +112,45 @@ export const hitElementBoundingBox = (
y1 -= tolerance; y1 -= tolerance;
x2 += tolerance; x2 += tolerance;
y2 += tolerance; y2 += tolerance;
return isPointWithinBounds( return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
pointFrom(x1, y1),
pointFrom(x, y),
pointFrom(x2, y2),
);
}; };
export const hitElementBoundingBoxOnly = < export const hitElementBoundingBoxOnly = (
Point extends GlobalPoint | LocalPoint, hitArgs: HitTestArgs,
>(
hitArgs: HitTestArgs<Point>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
return ( return (
!hitElementItself(hitArgs) && !hitElementItself(hitArgs) &&
// bound text is considered part of the element (even if it's outside the bounding box) // bound text is considered part of the element (even if it's outside the bounding box)
!hitElementBoundText( !hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
hitArgs.x, hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap)
hitArgs.y,
getBoundTextShape(hitArgs.element, elementsMap),
) &&
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
); );
}; };
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>( export const hitElementBoundText = (
x: number, point: GlobalPoint,
y: number, element: ExcalidrawElement,
textShape: GeometricShape<Point> | null, elementsMap: ElementsMap,
): boolean => { ): boolean => {
return !!textShape && isPointInShape(pointFrom(x, y), textShape); const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
if (!boundTextElementCandidate) {
return false;
}
const boundTextElement = isLinearElement(element)
? {
...boundTextElementCandidate,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElementCandidate,
elementsMap,
),
}
: boundTextElementCandidate;
return isPointInShape(point, boundTextElement);
}; };
/** /**

View File

@ -1,6 +1,8 @@
import { import {
curvePointDistance, curvePointDistance,
distanceToLineSegment, distanceToLineSegment,
lineSegment,
pointFrom,
pointRotateRads, pointRotateRads,
} from "@excalidraw/math"; } from "@excalidraw/math";
@ -16,17 +18,18 @@ import {
} from "./utils"; } from "./utils";
import type { import type {
ExcalidrawBindableElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
export const distanceToBindableElement = ( export const distanceToElement = (
element: ExcalidrawBindableElement, element: ExcalidrawElement,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
switch (element.type) { switch (element.type) {
case "selection":
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
@ -39,6 +42,23 @@ export const distanceToBindableElement = (
return distanceToDiamondElement(element, p); return distanceToDiamondElement(element, p);
case "ellipse": case "ellipse":
return distanceToEllipseElement(element, p); return distanceToEllipseElement(element, p);
case "line":
case "arrow":
case "freedraw":
return element.points.reduce((acc, point, idx) => {
if (idx === 0) {
return acc;
}
const prevPoint = element.points[idx - 1];
const segment = lineSegment(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
);
return Math.min(acc, distanceToLineSegment(p, segment));
}, Infinity);
} }
}; };

View File

@ -32,7 +32,7 @@ import {
snapToMid, snapToMid,
getHoveredElementForBinding, getHoveredElementForBinding,
} from "./binding"; } from "./binding";
import { distanceToBindableElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
compareHeading, compareHeading,
flipHeading, flipHeading,
@ -2234,8 +2234,7 @@ const getGlobalPoint = (
// NOTE: Resize scales the binding position point too, so we need to update it // NOTE: Resize scales the binding position point too, so we need to update it
return Math.abs( return Math.abs(
distanceToBindableElement(element, fixedGlobalPoint) - distanceToElement(element, fixedGlobalPoint) - FIXED_BINDING_DISTANCE,
FIXED_BINDING_DISTANCE,
) > 0.01 ) > 0.01
? bindPointToSnapToElementOutline(arrow, element, startOrEnd) ? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
: fixedGlobalPoint; : fixedGlobalPoint;
@ -2257,7 +2256,7 @@ const getBindPointHeading = (
hoveredElement && hoveredElement &&
aabbForElement( aabbForElement(
hoveredElement, hoveredElement,
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [ Array(4).fill(distanceToElement(hoveredElement, p)) as [
number, number,
number, number,
number, number,

View File

@ -195,7 +195,8 @@ export type ExcalidrawRectanguloidElement =
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawIframeLikeElement | ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement | ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement; | ExcalidrawEmbeddableElement
| ExcalidrawSelectionElement;
/** /**
* ExcalidrawElement should be JSON serializable and (eventually) contain * ExcalidrawElement should be JSON serializable and (eventually) contain

View File

@ -18,7 +18,6 @@ import {
vectorNormalize, vectorNormalize,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isPointInShape } from "@excalidraw/utils/collision"; import { isPointInShape } from "@excalidraw/utils/collision";
import { getSelectionBoxShape } from "@excalidraw/utils/shape";
import { import {
COLOR_PALETTE, COLOR_PALETTE,
@ -170,12 +169,7 @@ import {
isInvisiblySmallElement, isInvisiblySmallElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { import { getCornerRadius, isPathALoop } from "@excalidraw/element";
getBoundTextShape,
getCornerRadius,
getElementShape,
isPathALoop,
} from "@excalidraw/element";
import { import {
createSrcDoc, createSrcDoc,
@ -5141,13 +5135,8 @@ class App extends React.Component<AppProps, AppState> {
// If we're hitting element with highest z-index only on its bounding box // If we're hitting element with highest z-index only on its bounding box
// while also hitting other element figure, the latter should be considered. // while also hitting other element figure, the latter should be considered.
return hitElementItself({ return hitElementItself({
x, point: pointFrom(x, y),
y,
element: elementWithHighestZIndex, element: elementWithHighestZIndex,
shape: getElementShape(
elementWithHighestZIndex,
this.scene.getNonDeletedElementsMap(),
),
// when overlapping, we would like to be more precise // when overlapping, we would like to be more precise
// this also avoids the need to update past tests // this also avoids the need to update past tests
threshold: this.getElementHitThreshold() / 2, threshold: this.getElementHitThreshold() / 2,
@ -5229,34 +5218,26 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedElementIds[element.id] && this.state.selectedElementIds[element.id] &&
shouldShowBoundingBox([element], this.state) shouldShowBoundingBox([element], this.state)
) { ) {
const selectionShape = getSelectionBoxShape(
element,
this.scene.getNonDeletedElementsMap(),
isImageElement(element) ? 0 : this.getElementHitThreshold(),
);
// if hitting the bounding box, return early // if hitting the bounding box, return early
// but if not, we should check for other cases as well (e.g. frame name) // but if not, we should check for other cases as well (e.g. frame name)
if (isPointInShape(pointFrom(x, y), selectionShape)) { if (isPointInShape(pointFrom(x, y), element)) {
return true; return true;
} }
} }
// take bound text element into consideration for hit collision as well // take bound text element into consideration for hit collision as well
const hitBoundTextOfElement = hitElementBoundText( const hitBoundTextOfElement = hitElementBoundText(
x, pointFrom(x, y),
y, element,
getBoundTextShape(element, this.scene.getNonDeletedElementsMap()), this.scene.getNonDeletedElementsMap(),
); );
if (hitBoundTextOfElement) { if (hitBoundTextOfElement) {
return true; return true;
} }
return hitElementItself({ return hitElementItself({
x, point: pointFrom(x, y),
y,
element, element,
shape: getElementShape(element, this.scene.getNonDeletedElementsMap()),
threshold: this.getElementHitThreshold(), threshold: this.getElementHitThreshold(),
frameNameBound: isFrameLikeElement(element) frameNameBound: isFrameLikeElement(element)
? this.frameNameBoundsCache.get(element) ? this.frameNameBoundsCache.get(element)
@ -5285,13 +5266,8 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
isArrowElement(elements[index]) && isArrowElement(elements[index]) &&
hitElementItself({ hitElementItself({
x, point: pointFrom(x, y),
y,
element: elements[index], element: elements[index],
shape: getElementShape(
elements[index],
this.scene.getNonDeletedElementsMap(),
),
threshold: this.getElementHitThreshold(), threshold: this.getElementHitThreshold(),
}) })
) { ) {
@ -5637,13 +5613,8 @@ class App extends React.Component<AppProps, AppState> {
hasBoundTextElement(container) || hasBoundTextElement(container) ||
!isTransparent(container.backgroundColor) || !isTransparent(container.backgroundColor) ||
hitElementItself({ hitElementItself({
x: sceneX, point: pointFrom(sceneX, sceneY),
y: sceneY,
element: container, element: container,
shape: getElementShape(
container,
this.scene.getNonDeletedElementsMap(),
),
threshold: this.getElementHitThreshold(), threshold: this.getElementHitThreshold(),
}) })
) { ) {
@ -6316,13 +6287,8 @@ class App extends React.Component<AppProps, AppState> {
let segmentMidPointHoveredCoords = null; let segmentMidPointHoveredCoords = null;
if ( if (
hitElementItself({ hitElementItself({
x: scenePointerX, point: pointFrom(scenePointerX, scenePointerY),
y: scenePointerY,
element, element,
shape: getElementShape(
element,
this.scene.getNonDeletedElementsMap(),
),
}) })
) { ) {
hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor( hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
@ -9696,13 +9662,11 @@ class App extends React.Component<AppProps, AppState> {
((hitElement && ((hitElement &&
hitElementBoundingBoxOnly( hitElementBoundingBoxOnly(
{ {
x: pointerDownState.origin.x, point: pointFrom(
y: pointerDownState.origin.y, pointerDownState.origin.x,
element: hitElement, pointerDownState.origin.y,
shape: getElementShape(
hitElement,
this.scene.getNonDeletedElementsMap(),
), ),
element: hitElement,
threshold: this.getElementHitThreshold(), threshold: this.getElementHitThreshold(),
frameNameBound: isFrameLikeElement(hitElement) frameNameBound: isFrameLikeElement(hitElement)
? this.frameNameBoundsCache.get(hitElement) ? this.frameNameBoundsCache.get(hitElement)

View File

@ -463,7 +463,7 @@ const shouldHideLinkPopup = (
const threshold = 15 / appState.zoom.value; const threshold = 15 / appState.zoom.value;
// hitbox to prevent hiding when hovered in element bounding box // hitbox to prevent hiding when hovered in element bounding box
if (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) { if (hitElementBoundingBox(pointFrom(sceneX, sceneY), element, elementsMap)) {
return false; return false;
} }
const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);

View File

@ -92,7 +92,7 @@ export const isPointHittingLink = (
if ( if (
!isMobile && !isMobile &&
appState.viewModeEnabled && appState.viewModeEnabled &&
hitElementBoundingBox(x, y, element, elementsMap) hitElementBoundingBox(pointFrom(x, y), element, elementsMap)
) { ) {
return true; return true;
} }

View File

@ -8,7 +8,6 @@ import {
import { getElementsInGroup } from "@excalidraw/element"; import { getElementsInGroup } from "@excalidraw/element";
import { getElementShape } from "@excalidraw/element";
import { shouldTestInside } from "@excalidraw/element"; import { shouldTestInside } from "@excalidraw/element";
import { isPointInShape } from "@excalidraw/utils/collision"; import { isPointInShape } from "@excalidraw/utils/collision";
import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element"; import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
@ -208,15 +207,8 @@ const eraserTest = (
elementsMap: ElementsMap, elementsMap: ElementsMap,
app: App, app: App,
): boolean => { ): boolean => {
let shape = shapesCache.get(element.id);
if (!shape) {
shape = getElementShape<GlobalPoint>(element, elementsMap);
shapesCache.set(element.id, shape);
}
const lastPoint = pathSegments[pathSegments.length - 1][1]; const lastPoint = pathSegments[pathSegments.length - 1][1];
if (shouldTestInside(element) && isPointInShape(lastPoint, shape)) { if (shouldTestInside(element) && isPointInShape(lastPoint, element)) {
return true; return true;
} }

View File

@ -187,16 +187,10 @@ const renderBindingHighlightForBindableElement = (
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"], zoom: InteractiveCanvasAppState["zoom"],
) => { ) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const width = x2 - x1;
const height = y2 - y1;
context.strokeStyle = "rgba(0,0,0,.05)";
context.fillStyle = "rgba(0,0,0,.05)";
// To ensure the binding highlight doesn't overlap the element itself
const padding = maxBindingGap(element, element.width, element.height, zoom); const padding = maxBindingGap(element, element.width, element.height, zoom);
context.fillStyle = "rgba(0,0,0,.05)";
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "text": case "text":
@ -211,9 +205,12 @@ const renderBindingHighlightForBindableElement = (
drawHighlightForDiamondWithRotation(context, padding, element); drawHighlightForDiamondWithRotation(context, padding, element);
break; break;
case "ellipse": case "ellipse":
context.lineWidth = const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
maxBindingGap(element, element.width, element.height, zoom) - const width = x2 - x1;
FIXED_BINDING_DISTANCE; const height = y2 - y1;
context.strokeStyle = "rgba(0,0,0,.05)";
context.lineWidth = padding - FIXED_BINDING_DISTANCE;
strokeEllipseWithRotation( strokeEllipseWithRotation(
context, context,

View File

@ -3,69 +3,45 @@ import {
pointFrom, pointFrom,
polygonIncludesPoint, polygonIncludesPoint,
pointOnLineSegment, pointOnLineSegment,
pointOnPolygon,
polygonFromPoints,
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
type Polygon, type Polygon,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { intersectElementWithLineSegment } from "@excalidraw/element/collision";
import { elementCenterPoint } from "@excalidraw/common";
import { distanceToElement } from "@excalidraw/element/distance";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import { pointInEllipse, pointOnEllipse } from "./shape"; import type { Polyline } from "./shape";
import type { Polycurve, Polyline, GeometricShape } from "./shape";
// check if the given point is considered on the given shape's border // check if the given point is considered on the given shape's border
export const isPointOnShape = <Point extends GlobalPoint | LocalPoint>( export const isPointOnShape = (
point: Point, point: GlobalPoint,
shape: GeometricShape<Point>, element: ExcalidrawElement,
tolerance = 0, tolerance = 1,
) => { ) => {
// get the distance from the given point to the given element const distance = distanceToElement(element, point);
// check if the distance is within the given epsilon range
switch (shape.type) { return distance <= tolerance;
case "polygon":
return pointOnPolygon(point, shape.data, tolerance);
case "ellipse":
return pointOnEllipse(point, shape.data, tolerance);
case "line":
return pointOnLineSegment(point, shape.data, tolerance);
case "polyline":
return pointOnPolyline(point, shape.data, tolerance);
case "curve":
return pointOnCurve(point, shape.data, tolerance);
case "polycurve":
return pointOnPolycurve(point, shape.data, tolerance);
default:
throw Error(`shape ${shape} is not implemented`);
}
}; };
// check if the given point is considered inside the element's border // check if the given point is considered inside the element's border
export const isPointInShape = <Point extends GlobalPoint | LocalPoint>( export const isPointInShape = (
point: Point, point: GlobalPoint,
shape: GeometricShape<Point>, element: ExcalidrawElement,
) => { ) => {
switch (shape.type) { const intersections = intersectElementWithLineSegment(
case "polygon": element,
return polygonIncludesPoint(point, shape.data); lineSegment(elementCenterPoint(element), point),
case "line": );
return false;
case "curve": return intersections.length === 0;
return false;
case "ellipse":
return pointInEllipse(point, shape.data);
case "polyline": {
const polygon = polygonFromPoints(shape.data.flat());
return polygonIncludesPoint(point, polygon);
}
case "polycurve": {
return false;
}
default:
throw Error(`shape ${shape} is not implemented`);
}
}; };
// check if the given element is in the given bounds // check if the given element is in the given bounds
@ -76,14 +52,6 @@ export const isPointInBounds = <Point extends GlobalPoint | LocalPoint>(
return polygonIncludesPoint(point, bounds); return polygonIncludesPoint(point, bounds);
}; };
const pointOnPolycurve = <Point extends LocalPoint | GlobalPoint>(
point: Point,
polycurve: Polycurve<Point>,
tolerance: number,
) => {
return polycurve.some((curve) => pointOnCurve(point, curve, tolerance));
};
const cubicBezierEquation = <Point extends LocalPoint | GlobalPoint>( const cubicBezierEquation = <Point extends LocalPoint | GlobalPoint>(
curve: Curve<Point>, curve: Curve<Point>,
) => { ) => {