diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/element/collision.ts | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/element/collision.ts')
| -rw-r--r-- | packages/excalidraw/element/collision.ts | 312 |
1 files changed, 312 insertions, 0 deletions
diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts new file mode 100644 index 0000000..b0a2ce9 --- /dev/null +++ b/packages/excalidraw/element/collision.ts @@ -0,0 +1,312 @@ +import type { + ElementsMap, + ExcalidrawDiamondElement, + ExcalidrawElement, + ExcalidrawEllipseElement, + ExcalidrawRectangleElement, + ExcalidrawRectanguloidElement, +} from "./types"; +import { getElementBounds } from "./bounds"; +import type { FrameNameBounds } from "../types"; +import type { GeometricShape } from "@excalidraw/utils/geometry/shape"; +import { getPolygonShape } from "@excalidraw/utils/geometry/shape"; +import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision"; +import { isTransparent } from "../utils"; +import { + hasBoundTextElement, + isIframeLikeElement, + isImageElement, + isTextElement, +} from "./typeChecks"; +import { getBoundTextShape, isPathALoop } from "../shapes"; +import type { + GlobalPoint, + LineSegment, + LocalPoint, + Polygon, + Radians, +} from "@excalidraw/math"; +import { + curveIntersectLineSegment, + isPointWithinBounds, + line, + lineSegment, + lineSegmentIntersectionPoints, + pointFrom, + pointRotateRads, + pointsEqual, +} from "@excalidraw/math"; +import { + ellipse, + ellipseLineIntersectionPoints, +} from "@excalidraw/math/ellipse"; +import { + deconstructDiamondElement, + deconstructRectanguloidElement, +} from "./utils"; + +export const shouldTestInside = (element: ExcalidrawElement) => { + if (element.type === "arrow") { + return false; + } + + const isDraggableFromInside = + !isTransparent(element.backgroundColor) || + hasBoundTextElement(element) || + isIframeLikeElement(element) || + isTextElement(element); + + if (element.type === "line") { + return isDraggableFromInside && isPathALoop(element.points); + } + + if (element.type === "freedraw") { + return isDraggableFromInside && isPathALoop(element.points); + } + + return isDraggableFromInside || isImageElement(element); +}; + +export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = { + x: number; + y: number; + element: ExcalidrawElement; + shape: GeometricShape<Point>; + threshold?: number; + frameNameBound?: FrameNameBounds | null; +}; + +export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({ + x, + y, + element, + shape, + threshold = 10, + frameNameBound = null, +}: HitTestArgs<Point>) => { + 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); + + // 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>, + }); + } + + return hit; +}; + +export const hitElementBoundingBox = ( + x: number, + y: number, + element: ExcalidrawElement, + elementsMap: ElementsMap, + tolerance = 0, +) => { + let [x1, y1, x2, y2] = getElementBounds(element, elementsMap); + x1 -= tolerance; + y1 -= tolerance; + x2 += tolerance; + y2 += tolerance; + return isPointWithinBounds( + pointFrom(x1, y1), + pointFrom(x, y), + pointFrom(x2, y2), + ); +}; + +export const hitElementBoundingBoxOnly = < + Point extends GlobalPoint | LocalPoint, +>( + hitArgs: HitTestArgs<Point>, + 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) + ); +}; + +export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>( + x: number, + y: number, + textShape: GeometricShape<Point> | null, +): boolean => { + return !!textShape && isPointInShape(pointFrom(x, y), textShape); +}; + +/** + * Intersect a line with an element for binding test + * + * @param element + * @param line + * @param offset + * @returns + */ +export const intersectElementWithLineSegment = ( + element: ExcalidrawElement, + line: LineSegment<GlobalPoint>, + offset: number = 0, +): GlobalPoint[] => { + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + return intersectRectanguloidWithLineSegment(element, line, offset); + case "diamond": + return intersectDiamondWithLineSegment(element, line, offset); + case "ellipse": + return intersectEllipseWithLineSegment(element, line, offset); + default: + throw new Error(`Unimplemented element type '${element.type}'`); + } +}; + +const intersectRectanguloidWithLineSegment = ( + element: ExcalidrawRectanguloidElement, + l: LineSegment<GlobalPoint>, + offset: number = 0, +): GlobalPoint[] => { + const center = pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height / 2, + ); + // To emulate a rotated rectangle we rotate the point in the inverse angle + // instead. It's all the same distance-wise. + const rotatedA = pointRotateRads<GlobalPoint>( + l[0], + center, + -element.angle as Radians, + ); + const rotatedB = pointRotateRads<GlobalPoint>( + l[1], + center, + -element.angle as Radians, + ); + + // Get the element's building components we can test against + const [sides, corners] = deconstructRectanguloidElement(element, offset); + + return ( + [ + // Test intersection against the sides, keep only the valid + // intersection points and rotate them back to scene space + ...sides + .map((s) => + lineSegmentIntersectionPoints( + lineSegment<GlobalPoint>(rotatedA, rotatedB), + s, + ), + ) + .filter((x) => x != null) + .map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle)), + // Test intersection against the corners which are cubic bezier curves, + // keep only the valid intersection points and rotate them back to scene + // space + ...corners + .flatMap((t) => + curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)), + ) + .filter((i) => i != null) + .map((j) => pointRotateRads(j, center, element.angle)), + ] + // Remove duplicates + .filter( + (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, + ) + ); +}; + +/** + * + * @param element + * @param a + * @param b + * @returns + */ +const intersectDiamondWithLineSegment = ( + element: ExcalidrawDiamondElement, + l: LineSegment<GlobalPoint>, + offset: number = 0, +): GlobalPoint[] => { + const center = pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + // Rotate the point to the inverse direction to simulate the rotated diamond + // points. It's all the same distance-wise. + const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); + const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); + + const [sides, curves] = deconstructDiamondElement(element, offset); + + return ( + [ + ...sides + .map((s) => + lineSegmentIntersectionPoints( + lineSegment<GlobalPoint>(rotatedA, rotatedB), + s, + ), + ) + .filter((p): p is GlobalPoint => p != null) + // Rotate back intersection points + .map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle)), + ...curves + .flatMap((p) => + curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)), + ) + .filter((p) => p != null) + // Rotate back intersection points + .map((p) => pointRotateRads(p, center, element.angle)), + ] + // Remove duplicates + .filter( + (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, + ) + ); +}; + +/** + * + * @param element + * @param a + * @param b + * @returns + */ +const intersectEllipseWithLineSegment = ( + element: ExcalidrawEllipseElement, + l: LineSegment<GlobalPoint>, + offset: number = 0, +): GlobalPoint[] => { + const center = pointFrom<GlobalPoint>( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); + const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); + + return ellipseLineIntersectionPoints( + ellipse(center, element.width / 2 + offset, element.height / 2 + offset), + line(rotatedA, rotatedB), + ).map((p) => pointRotateRads(p, center, element.angle)); +}; |
