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 6b5fb30d69
commit 04e1bf0bc4
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,
} from "@excalidraw/math";
import { isPointOnShape } from "@excalidraw/utils/collision";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
@ -41,7 +39,7 @@ import {
doBoundsIntersect,
} from "./bounds";
import { intersectElementWithLineSegment } from "./collision";
import { distanceToBindableElement } from "./distance";
import { distanceToElement } from "./distance";
import {
headingForPointFromElement,
headingIsHorizontal,
@ -63,7 +61,7 @@ import {
isTextElement,
} from "./typeChecks";
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
import { aabbForElement } from "./shapes";
import { updateElbowArrowPoints } from "./elbowArrow";
import type { Scene } from "./Scene";
@ -704,7 +702,7 @@ const calculateFocusAndGap = (
return {
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,
zoom?: AppState["zoom"],
) => {
const distance = distanceToBindableElement(bindableElement, point);
const distance = distanceToElement(bindableElement, point);
const bindDistance = maxBindingGap(
bindableElement,
bindableElement.width,
@ -1548,14 +1546,22 @@ export const bindingBorderTest = (
zoom?: AppState["zoom"],
fullShape?: boolean,
): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const threshold = maxBindingGap(element, element.width, element.height, zoom);
const shape = getElementShape(element, elementsMap);
return (
isPointOnShape(pointFrom(x, y), shape, threshold) ||
(fullShape === true &&
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
const shouldTestInside =
// disable fullshape snapping for frame elements so we
// can bind to frame children
(fullShape || !isBindingFallthroughEnabled(element)) &&
!isFrameLikeElement(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 = (

View File

@ -16,24 +16,18 @@ import {
} from "@excalidraw/math/ellipse";
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
import type {
GlobalPoint,
LineSegment,
LocalPoint,
Polygon,
Radians,
} from "@excalidraw/math";
import type { GlobalPoint, LineSegment, Radians } from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { getBoundTextShape, isPathALoop } from "./shapes";
import { isPathALoop } from "./shapes";
import { getElementBounds } from "./bounds";
import {
hasBoundTextElement,
isIframeLikeElement,
isImageElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import {
@ -41,12 +35,15 @@ import {
deconstructRectanguloidElement,
} from "./utils";
import { getBoundTextElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import type {
ElementsMap,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawRectangleElement,
ExcalidrawRectanguloidElement,
} from "./types";
@ -72,45 +69,40 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
return isDraggableFromInside || isImageElement(element);
};
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
x: number;
y: number;
export type HitTestArgs = {
point: GlobalPoint;
element: ExcalidrawElement;
shape: GeometricShape<Point>;
threshold?: number;
frameNameBound?: FrameNameBounds | null;
};
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
x,
y,
export const hitElementItself = ({
point,
element,
shape,
threshold = 10,
frameNameBound = null,
}: HitTestArgs<Point>) => {
}: HitTestArgs) => {
let hit = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInShape(pointFrom(x, y), shape) ||
isPointOnShape(pointFrom(x, y), shape, threshold)
: isPointOnShape(pointFrom(x, y), shape, threshold);
isPointInShape(point, element) ||
isPointOnShape(point, element, threshold)
: isPointOnShape(point, element, threshold);
// hit test against a frame's name
if (!hit && frameNameBound) {
hit = isPointInShape(pointFrom(x, y), {
type: "polygon",
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
.data as Polygon<Point>,
});
const x1 = frameNameBound.x - threshold;
const y1 = frameNameBound.y - threshold;
const x2 = frameNameBound.x + frameNameBound.width + threshold;
const y2 = frameNameBound.y + frameNameBound.height + threshold;
hit = isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
}
return hit;
};
export const hitElementBoundingBox = (
x: number,
y: number,
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 0,
@ -120,37 +112,45 @@ export const hitElementBoundingBox = (
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds(
pointFrom(x1, y1),
pointFrom(x, y),
pointFrom(x2, y2),
);
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
};
export const hitElementBoundingBoxOnly = <
Point extends GlobalPoint | LocalPoint,
>(
hitArgs: HitTestArgs<Point>,
export const hitElementBoundingBoxOnly = (
hitArgs: HitTestArgs,
elementsMap: ElementsMap,
) => {
return (
!hitElementItself(hitArgs) &&
// bound text is considered part of the element (even if it's outside the bounding box)
!hitElementBoundText(
hitArgs.x,
hitArgs.y,
getBoundTextShape(hitArgs.element, elementsMap),
) &&
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap)
);
};
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
x: number,
y: number,
textShape: GeometricShape<Point> | null,
export const hitElementBoundText = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
): 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 {
curvePointDistance,
distanceToLineSegment,
lineSegment,
pointFrom,
pointRotateRads,
} from "@excalidraw/math";
@ -16,17 +18,18 @@ import {
} from "./utils";
import type {
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawRectanguloidElement,
} from "./types";
export const distanceToBindableElement = (
element: ExcalidrawBindableElement,
export const distanceToElement = (
element: ExcalidrawElement,
p: GlobalPoint,
): number => {
switch (element.type) {
case "selection":
case "rectangle":
case "image":
case "text":
@ -39,6 +42,23 @@ export const distanceToBindableElement = (
return distanceToDiamondElement(element, p);
case "ellipse":
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,
getHoveredElementForBinding,
} from "./binding";
import { distanceToBindableElement } from "./distance";
import { distanceToElement } from "./distance";
import {
compareHeading,
flipHeading,
@ -2234,8 +2234,7 @@ const getGlobalPoint = (
// NOTE: Resize scales the binding position point too, so we need to update it
return Math.abs(
distanceToBindableElement(element, fixedGlobalPoint) -
FIXED_BINDING_DISTANCE,
distanceToElement(element, fixedGlobalPoint) - FIXED_BINDING_DISTANCE,
) > 0.01
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
: fixedGlobalPoint;
@ -2257,7 +2256,7 @@ const getBindPointHeading = (
hoveredElement &&
aabbForElement(
hoveredElement,
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [
Array(4).fill(distanceToElement(hoveredElement, p)) as [
number,
number,
number,

View File

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

View File

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

View File

@ -463,7 +463,7 @@ const shouldHideLinkPopup = (
const threshold = 15 / appState.zoom.value;
// 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;
}
const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);

View File

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

View File

@ -8,7 +8,6 @@ import {
import { getElementsInGroup } from "@excalidraw/element";
import { getElementShape } from "@excalidraw/element";
import { shouldTestInside } from "@excalidraw/element";
import { isPointInShape } from "@excalidraw/utils/collision";
import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
@ -208,15 +207,8 @@ const eraserTest = (
elementsMap: ElementsMap,
app: App,
): 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];
if (shouldTestInside(element) && isPointInShape(lastPoint, shape)) {
if (shouldTestInside(element) && isPointInShape(lastPoint, element)) {
return true;
}

View File

@ -187,16 +187,10 @@ const renderBindingHighlightForBindableElement = (
elementsMap: ElementsMap,
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);
context.fillStyle = "rgba(0,0,0,.05)";
switch (element.type) {
case "rectangle":
case "text":
@ -211,9 +205,12 @@ const renderBindingHighlightForBindableElement = (
drawHighlightForDiamondWithRotation(context, padding, element);
break;
case "ellipse":
context.lineWidth =
maxBindingGap(element, element.width, element.height, zoom) -
FIXED_BINDING_DISTANCE;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const width = x2 - x1;
const height = y2 - y1;
context.strokeStyle = "rgba(0,0,0,.05)";
context.lineWidth = padding - FIXED_BINDING_DISTANCE;
strokeEllipseWithRotation(
context,

View File

@ -3,69 +3,45 @@ import {
pointFrom,
polygonIncludesPoint,
pointOnLineSegment,
pointOnPolygon,
polygonFromPoints,
type GlobalPoint,
type LocalPoint,
type Polygon,
} 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 { pointInEllipse, pointOnEllipse } from "./shape";
import type { Polycurve, Polyline, GeometricShape } from "./shape";
import type { Polyline } from "./shape";
// check if the given point is considered on the given shape's border
export const isPointOnShape = <Point extends GlobalPoint | LocalPoint>(
point: Point,
shape: GeometricShape<Point>,
tolerance = 0,
export const isPointOnShape = (
point: GlobalPoint,
element: ExcalidrawElement,
tolerance = 1,
) => {
// get the distance from the given point to the given element
// check if the distance is within the given epsilon range
switch (shape.type) {
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`);
}
const distance = distanceToElement(element, point);
return distance <= tolerance;
};
// check if the given point is considered inside the element's border
export const isPointInShape = <Point extends GlobalPoint | LocalPoint>(
point: Point,
shape: GeometricShape<Point>,
export const isPointInShape = (
point: GlobalPoint,
element: ExcalidrawElement,
) => {
switch (shape.type) {
case "polygon":
return polygonIncludesPoint(point, shape.data);
case "line":
return false;
case "curve":
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`);
}
const intersections = intersectElementWithLineSegment(
element,
lineSegment(elementCenterPoint(element), point),
);
return intersections.length === 0;
};
// 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);
};
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>(
curve: Curve<Point>,
) => {