First implementation of element distance functions with failing tests

pull/8539/merge^2
Mark Tolmacs 6 months ago
parent 47cc842415
commit d9ea7190ec
No known key found for this signature in database

@ -25,7 +25,6 @@ import type {
ExcalidrawElbowArrowElement,
FixedPoint,
SceneElementsMap,
ExcalidrawRectanguloidElement,
} from "./types";
import type { Bounds } from "./bounds";
@ -63,7 +62,7 @@ import {
vectorToHeading,
type Heading,
} from "./heading";
import type { LocalPoint, Radians } from "../../math";
import type { LocalPoint } from "../../math";
import {
segment,
point,
@ -76,6 +75,7 @@ import {
radians,
} from "../../math";
import { segmentIntersectRectangleElement } from "../../utils/geometry/shape";
import { distanceToBindableElement } from "./distance";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@ -557,10 +557,7 @@ const calculateFocusAndGap = (
edgePoint,
elementsMap,
),
gap: Math.max(
1,
distanceToBindableElement(hoveredElement, edgePoint, elementsMap),
),
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
};
};
@ -736,11 +733,7 @@ const getDistanceForBinding = (
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
) => {
const distance = distanceToBindableElement(
bindableElement,
point,
elementsMap,
);
const distance = distanceToBindableElement(bindableElement, point);
const bindDistance = maxBindingGap(
bindableElement,
bindableElement.width,
@ -781,9 +774,7 @@ export const bindPointToSnapToElementOutline = (
const isVertical =
compareHeading(heading, HEADING_LEFT) ||
compareHeading(heading, HEADING_RIGHT);
const dist = Math.abs(
distanceToBindableElement(bindableElement, p, elementsMap),
);
const dist = Math.abs(distanceToBindableElement(bindableElement, p));
const isInner = isVertical
? dist < bindableElement.width * -0.1
: dist < bindableElement.height * -0.1;
@ -937,7 +928,7 @@ export const snapToMid = (
): GlobalPoint => {
const { x, y, width, height, angle } = element;
const center = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians);
const nonRotated = pointRotateRads(p, center, radians(-angle));
// snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance
@ -1123,7 +1114,7 @@ export const calculateFixedPointForElbowArrowBinding = (
const nonRotatedSnappedGlobalPoint = pointRotateRads(
snappedPoint,
globalMidPoint,
-hoveredElement.angle as Radians,
radians(-hoveredElement.angle),
);
return {
@ -1351,148 +1342,6 @@ export const maxBindingGap = (
return Math.max(16, Math.min(0.25 * smallerDimension, 32));
};
export const distanceToBindableElement = (
element: ExcalidrawBindableElement,
point: GlobalPoint,
elementsMap: ElementsMap,
): number => {
switch (element.type) {
case "rectangle":
case "image":
case "text":
case "iframe":
case "embeddable":
case "frame":
case "magicframe":
return distanceToRectangle(element, point, elementsMap);
case "diamond":
return distanceToDiamond(element, point, elementsMap);
case "ellipse":
return distanceToEllipse(element, point, elementsMap);
}
};
const distanceToRectangle = (
element: ExcalidrawRectanguloidElement,
p: GlobalPoint,
elementsMap: ElementsMap,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
element,
p,
elementsMap,
);
return Math.max(
GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)),
GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)),
);
};
const distanceToDiamond = (
element: ExcalidrawDiamondElement,
point: GlobalPoint,
elementsMap: ElementsMap,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
element,
point,
elementsMap,
);
const side = GALine.equation(hheight, hwidth, -hheight * hwidth);
return GAPoint.distanceToLine(pointRel, side);
};
const distanceToEllipse = (
element: ExcalidrawEllipseElement,
point: GlobalPoint,
elementsMap: ElementsMap,
): number => {
const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap);
return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent);
};
const ellipseParamsForTest = (
element: ExcalidrawEllipseElement,
point: GlobalPoint,
elementsMap: ElementsMap,
): [GA.Point, GA.Line] => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
element,
point,
elementsMap,
);
const [px, py] = GAPoint.toTuple(pointRel);
// We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)`
let tx = 0.707;
let ty = 0.707;
const a = hwidth;
const b = hheight;
// This is a numerical method to find the params tx, ty at which
// the ellipse has the closest point to the given point
[0, 1, 2, 3].forEach((_) => {
const xx = a * tx;
const yy = b * ty;
const ex = ((a * a - b * b) * tx ** 3) / a;
const ey = ((b * b - a * a) * ty ** 3) / b;
const rx = xx - ex;
const ry = yy - ey;
const qx = px - ex;
const qy = py - ey;
const r = Math.hypot(ry, rx);
const q = Math.hypot(qy, qx);
tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
const t = Math.hypot(ty, tx);
tx /= t;
ty /= t;
});
const closestPoint = GA.point(a * tx, b * ty);
const tangent = GALine.orthogonalThrough(pointRel, closestPoint);
return [pointRel, tangent];
};
// Returns:
// 1. the point relative to the elements (x, y) position
// 2. the point relative to the element's center with positive (x, y)
// 3. half element width
// 4. half element height
//
// Note that for linear elements the (x, y) position is not at the
// top right corner of their boundary.
//
// Rectangles, diamonds and ellipses are symmetrical over axes,
// and other elements have a rectangular boundary,
// so we only need to perform hit tests for the positive quadrant.
const pointRelativeToElement = (
element: ExcalidrawElement,
pointTuple: GlobalPoint,
elementsMap: ElementsMap,
): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const center = coordsCenter(x1, y1, x2, y2);
// GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle);
const pointRotated = GATransform.apply(rotate, point);
const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
const elementPos = GA.offset(element.x, element.y);
const pointRelToPos = GA.sub(pointRotated, elementPos);
const halfWidth = (x2 - x1) / 2;
const halfHeight = (y2 - y1) / 2;
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
};
const relativizationToElementCenter = (
element: ExcalidrawElement,
elementsMap: ElementsMap,

@ -0,0 +1,210 @@
import type { GlobalPoint, Segment } from "../../math";
import {
arc,
arcDistanceFromPoint,
ellipse,
ellipseDistanceFromPoint,
ellipseSegmentInterceptPoints,
point,
pointRotateRads,
radians,
rectangle,
segment,
segmentDistanceToPoint,
} from "../../math";
import { getCornerRadius } from "../shapes";
import type {
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawEllipseElement,
ExcalidrawRectanguloidElement,
} from "./types";
export const distanceToBindableElement = (
element: ExcalidrawBindableElement,
point: GlobalPoint,
): number => {
switch (element.type) {
case "rectangle":
case "image":
case "text":
case "iframe":
case "embeddable":
case "frame":
case "magicframe":
return distanceToRectangleElement(element, point);
case "diamond":
return distanceToDiamondElement(element, point);
case "ellipse":
return distanceToEllipseElement(element, point);
}
};
export const distanceToRectangleElement = (
element: ExcalidrawRectanguloidElement,
p: GlobalPoint,
) => {
const center = point(
element.x + element.width / 2,
element.y + element.height / 2,
);
const r = rectangle(
pointRotateRads(
point(element.x, element.y),
center,
radians(element.angle),
),
pointRotateRads(
point(element.x + element.width, element.y + element.height),
center,
radians(element.angle),
),
);
const roundness = getCornerRadius(
Math.min(element.width, element.height),
element,
);
const rotatedPoint = pointRotateRads(p, center, element.angle);
const sideDistances = [
segment(
point(r[0][0] + roundness, r[0][1]),
point(r[1][0] - roundness, r[0][1]),
),
segment(
point(r[1][0], r[0][1] + roundness),
point(r[1][0], r[1][1] - roundness),
),
segment(
point(r[1][0] - roundness, r[1][1]),
point(r[0][0] + roundness, r[1][1]),
),
segment(
point(r[0][0], r[1][1] - roundness),
point(r[0][0], r[0][1] + roundness),
),
].map((s) => segmentDistanceToPoint(rotatedPoint, s));
const cornerDistances =
roundness > 0
? [
arc(
point(r[0][0] + roundness, r[0][1] + roundness),
roundness,
radians(Math.PI),
radians((3 / 4) * Math.PI),
),
arc(
point(r[1][0] - roundness, r[0][1] + roundness),
roundness,
radians((3 / 4) * Math.PI),
radians(0),
),
arc(
point(r[1][0] - roundness, r[1][1] - roundness),
roundness,
radians(0),
radians((1 / 2) * Math.PI),
),
arc(
point(r[0][0] + roundness, r[1][1] - roundness),
roundness,
radians((1 / 2) * Math.PI),
radians(Math.PI),
),
].map((a) => arcDistanceFromPoint(a, rotatedPoint))
: [];
return Math.min(...[...sideDistances, ...cornerDistances]);
};
const roundedCutoffSegment = (
s: Segment<GlobalPoint>,
r: number,
): Segment<GlobalPoint> => {
const t = (4 * r) / Math.sqrt(2);
return segment(
ellipseSegmentInterceptPoints(ellipse(s[0], radians(0), t, t), s)[0],
ellipseSegmentInterceptPoints(ellipse(s[1], radians(0), t, t), s)[0],
);
};
const diamondArc = (left: GlobalPoint, right: GlobalPoint, r: number) => {
const c = point((left[0] + right[0]) / 2, left[1]);
return arc(
c,
r,
radians(Math.asin((left[1] - c[1]) / r)),
radians(Math.asin((right[1] - c[1]) / r)),
);
};
export const distanceToDiamondElement = (
element: ExcalidrawDiamondElement,
p: GlobalPoint,
): number => {
const center = point<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
const roundness = getCornerRadius(
Math.min(element.width, element.height),
element,
);
const rotatedPoint = pointRotateRads(p, center, element.angle);
const top = pointRotateRads<GlobalPoint>(
point(element.x + element.width / 2, element.y),
center,
element.angle,
);
const right = pointRotateRads<GlobalPoint>(
point(element.x + element.width, element.y + element.height / 2),
center,
element.angle,
);
const bottom = pointRotateRads<GlobalPoint>(
point(element.x + element.width / 2, element.y + element.height),
center,
element.angle,
);
const left = pointRotateRads<GlobalPoint>(
point(element.x, element.y + element.height / 2),
center,
element.angle,
);
const topRight = roundedCutoffSegment(segment(top, right), roundness);
const bottomRight = roundedCutoffSegment(segment(right, bottom), roundness);
const bottomLeft = roundedCutoffSegment(segment(bottom, left), roundness);
const topLeft = roundedCutoffSegment(segment(left, top), roundness);
return Math.min(
...[
...[topRight, bottomRight, bottomLeft, topLeft].map((s) =>
segmentDistanceToPoint(rotatedPoint, s),
),
...(roundness > 0
? [
diamondArc(topLeft[1], topRight[0], roundness),
diamondArc(topRight[1], bottomRight[0], roundness),
diamondArc(bottomRight[1], bottomLeft[0], roundness),
diamondArc(bottomLeft[1], topLeft[0], roundness),
].map((a) => arcDistanceFromPoint(a, rotatedPoint))
: []),
],
);
};
export const distanceToEllipseElement = (
element: ExcalidrawEllipseElement,
p: GlobalPoint,
): number => {
return ellipseDistanceFromPoint(
p,
ellipse(
point(element.x + element.width / 2, element.y + element.height / 2),
element.angle,
element.width / 2,
element.height / 2,
),
);
};

@ -140,10 +140,10 @@ export const findShapeByKey = (key: string) => {
* get the pure geometric shape of an excalidraw element
* which is then used for hit detection
*/
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
export const getElementShape = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape<Point> => {
): GeometricShape<GlobalPoint> => {
switch (element.type) {
case "rectangle":
case "diamond":
@ -163,16 +163,16 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return shouldTestInside(element)
? getClosedCurveShape<Point>(
? getClosedCurveShape<GlobalPoint>(
element,
roughShape,
point<Point>(element.x, element.y),
point<GlobalPoint>(element.x, element.y),
element.angle,
point(cx, cy),
)
: getCurveShape<Point>(
: getCurveShape<GlobalPoint>(
roughShape,
point<Point>(element.x, element.y),
point<GlobalPoint>(element.x, element.y),
element.angle,
point(cx, cy),
);
@ -192,10 +192,10 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
}
};
export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
export const getBoundTextShape = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape<Point> | null => {
): GeometricShape<GlobalPoint> | null => {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {

@ -1,6 +1,8 @@
import { invariant } from "../excalidraw/utils";
import { cartesian2Polar, radians } from "./angle";
import { ellipse, ellipseSegmentInterceptPoints } from "./ellipse";
import { point } from "./point";
import { point, pointDistance } from "./point";
import { segment } from "./segment";
import type { GenericPoint, Segment, Radians, Arc } from "./types";
import { PRECISION } from "./utils";
@ -42,6 +44,25 @@ export function arcIncludesPoint<P extends GenericPoint>(
: startAngle <= angle || endAngle >= angle;
}
/**
*
* @param a
* @param p
*/
export function arcDistanceFromPoint<Point extends GenericPoint>(
a: Arc<Point>,
p: Point,
) {
const intersectPoint = arcSegmentInterceptPoint(a, segment(p, a.center));
invariant(
intersectPoint.length !== 1,
"Arc distance intersector cannot have multiple intersections",
);
return pointDistance(intersectPoint[0], p);
}
/**
* Returns the intersection point(s) of a line segment represented by a start
* point and end point and a symmetric arc.

@ -1,6 +1,6 @@
import { pointsEqual } from "./point";
import { segment, segmentIncludesPoint } from "./segment";
import type { GenericPoint, Polygon } from "./types";
import { segment, segmentIncludesPoint, segmentsIntersectAt } from "./segment";
import type { GenericPoint, Polygon, Segment } from "./types";
import { PRECISION } from "./utils";
export function polygon<Point extends GenericPoint>(...points: Point[]) {
@ -62,3 +62,25 @@ function polygonClose<Point extends GenericPoint>(polygon: Point[]) {
function polygonIsClosed<Point extends GenericPoint>(polygon: Point[]) {
return pointsEqual(polygon[0], polygon[polygon.length - 1]);
}
/**
* Returns the points of intersection of a line segment, identified by exactly
* one start pointand one end point, and the polygon identified by a set of
* ponits representing a set of connected lines.
*/
export function polygonSegmentIntersectionPoints<Point extends GenericPoint>(
polygon: Readonly<Polygon<Point>>,
segment: Readonly<Segment<Point>>,
): Point[] {
return polygon
.reduce((segments, current, idx, poly) => {
return idx === 0
? []
: ([
...segments,
[poly[idx - 1] as Point, current],
] as Segment<Point>[]);
}, [] as Segment<Point>[])
.map((s) => segmentsIntersectAt(s, segment))
.filter((point) => point !== null) as Point[];
}

@ -122,11 +122,11 @@ export const segmentIncludesPoint = <Point extends GenericPoint>(
};
export const segmentDistanceToPoint = <Point extends GenericPoint>(
point: Point,
line: Segment<Point>,
p: Point,
s: Segment<Point>,
) => {
const [x, y] = point;
const [[x1, y1], [x2, y2]] = line;
const [x, y] = p;
const [[x1, y1], [x2, y2]] = s;
const A = x - x1;
const B = y - y1;

Loading…
Cancel
Save