You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
success/packages/excalidraw/element/collision.ts

313 lines
8.4 KiB
TypeScript

import type {
ElementsMap,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawRectangleElement,
ExcalidrawRectanguloidElement,
} from "./types";
import { getElementBounds } from "./bounds";
import type { FrameNameBounds } from "../types";
import type { GeometricShape } from "../../utils/geometry/shape";
import { getPolygonShape } from "../../utils/geometry/shape";
import { isPointInShape, isPointOnShape } from "../../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 "../../math";
import {
curveIntersectLineSegment,
isPointWithinBounds,
line,
lineSegment,
lineSegmentIntersectionPoints,
pointFrom,
pointRotateRads,
pointsEqual,
} from "../../math";
import { ellipse, ellipseLineIntersectionPoints } from "../../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<GlobalPoint>(
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));
};