Bounds refactor and duplication removal

This commit is contained in:
Mark Tolmacs 2024-09-27 15:58:18 +02:00
parent 7b4e989d65
commit 91b6057d9c
No known key found for this signature in database
28 changed files with 431 additions and 147 deletions

View file

@ -25,9 +25,9 @@ import type {
ExcalidrawElbowArrowElement,
FixedPoint,
SceneElementsMap,
Bounds,
} from "./types";
import type { Bounds } from "./bounds";
import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
import type { AppState } from "../types";
import { isPointOnShape } from "../../utils/collision";

View file

@ -4,6 +4,7 @@ import type {
ExcalidrawFreeDrawElement,
ExcalidrawTextElementWithContainer,
ElementsMap,
Bounds,
} from "./types";
import rough from "roughjs/bin/rough";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
@ -34,17 +35,7 @@ import { getCurvePathOps } from "../../utils/geometry/shape";
type MaybeQuadraticSolution = [number | null, number | null] | false;
/**
* x and y position of top left corner, x and y position of bottom right corner
*/
export type Bounds = readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
];
export type SceneBounds = readonly [
export type ViewportBounds = readonly [
sceneX: number,
sceneY: number,
sceneX2: number,
@ -57,6 +48,7 @@ class ElementBounds {
{
bounds: Bounds;
version: ExcalidrawElement["version"];
versionNonce: ExcalidrawElement["versionNonce"];
}
>();
@ -66,6 +58,7 @@ class ElementBounds {
if (
cachedBounds?.version &&
cachedBounds.version === element.version &&
cachedBounds?.versionNonce === element.versionNonce &&
// we don't invalidate cache when we update containers and not labels,
// which is causing problems down the line. Fix TBA.
!isBoundToContainer(element)
@ -76,6 +69,7 @@ class ElementBounds {
ElementBounds.boundsCache.set(element, {
version: element.version,
versionNonce: element.versionNonce,
bounds,
});
@ -93,7 +87,7 @@ class ElementBounds {
elementsMap,
);
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
const [minX, minY, maxX, maxY] = getBoundsFromFreeDrawPoints(
element.points.map(([x, y]) =>
pointRotateRads(
point(x, y),
@ -177,6 +171,20 @@ class ElementBounds {
}
}
/**
* Get the axis-aligned bounds of the given element in global / scene coordinates
*
* @param element The element to determine the bounding box for
* @param elementsMap The elements map to retrieve attached elements (notably text label)
* @returns The axis-aligned bounding box in scene (global coordinates)
*/
export const getElementBounds = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): Bounds => {
return ElementBounds.getBounds(element, elementsMap);
};
// Scene -> Scene coords, but in x1,x2,y1,y2 format.
//
// If the element is created from right to left, the width is going to be negative
@ -224,21 +232,6 @@ export const getElementAbsoluteCoords = (
];
};
export const getDiamondPoints = (element: ExcalidrawElement) => {
// Here we add +1 to avoid these numbers to be 0
// otherwise rough.js will throw an error complaining about it
const topX = Math.floor(element.width / 2) + 1;
const topY = 0;
const rightX = element.width;
const rightY = Math.floor(element.height / 2) + 1;
const bottomX = topX;
const bottomY = element.height;
const leftX = 0;
const leftY = rightY;
return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];
};
// reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes
const getBezierValueForT = (
t: number,
@ -382,7 +375,7 @@ export const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY];
};
export const getBoundsFromPoints = (
const getBoundsFromFreeDrawPoints = (
points: ExcalidrawFreeDrawElement["points"],
): Bounds => {
let minX = Infinity;
@ -403,7 +396,7 @@ export const getBoundsFromPoints = (
const getFreeDrawElementAbsoluteCoords = (
element: ExcalidrawFreeDrawElement,
): [number, number, number, number, number, number] => {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
const [minX, minY, maxX, maxY] = getBoundsFromFreeDrawPoints(element.points);
const x1 = minX + element.x;
const y1 = minY + element.y;
const x2 = maxX + element.x;
@ -496,13 +489,6 @@ const getLinearElementRotatedBounds = (
return coords;
};
export const getElementBounds = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): Bounds => {
return ElementBounds.getBounds(element, elementsMap);
};
export const getCommonBounds = (
elements: readonly ExcalidrawElement[],
elementsMap?: ElementsMap,
@ -568,7 +554,7 @@ export const getResizedElementAbsoluteCoords = (
if (isFreeDrawElement(element)) {
// Free Draw
bounds = getBoundsFromPoints(points);
bounds = getBoundsFromFreeDrawPoints(points);
} else {
// Line
const gen = rough.generator();
@ -651,7 +637,7 @@ export const getVisibleSceneBounds = ({
width,
height,
zoom,
}: AppState): SceneBounds => {
}: AppState): ViewportBounds => {
return [
-scrollX,
-scrollY,

View file

@ -40,31 +40,33 @@ export const distanceToBindableElement = (
}
};
/**
* Returns the distance of a point and the provided rectangular-shaped element,
* accounting for roundness and rotation
*
* @param element The rectanguloid element
* @param p The point to consider
* @returns The eucledian distance to the outline of the rectanguloid element
*/
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),
),
point(element.x, element.y),
point(element.x + element.width, element.y + element.height),
);
// To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(
p,
point(element.x + element.width / 2, element.y + element.height / 2),
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]),
@ -116,10 +118,22 @@ export const distanceToRectangleElement = (
return Math.min(...[...sideDistances, ...cornerDistances]);
};
const roundedCutoffSegment = (
/**
* Shortens a segment on both ends to accomodate the arc in the rounded
* diamond shape
*
* @param s The segment to shorten
* @param r The radius to shorten by
* @returns The segment shortened on both ends by the same radius
*/
const createDiamondSide = (
s: Segment<GlobalPoint>,
r: number,
): Segment<GlobalPoint> => {
if (r === 0) {
return s;
}
const t = (4 * r) / Math.sqrt(2);
return segment(
@ -128,17 +142,36 @@ const roundedCutoffSegment = (
);
};
const diamondArc = (left: GlobalPoint, right: GlobalPoint, r: number) => {
const c = point((left[0] + right[0]) / 2, left[1]);
/**
* Creates an arc for the given roundness and position by taking the start
* and end positions and determining the angle points on the hypotethical
* circle with center point between start and end and raidus equals provided
* roundness. I.e. the created arc is gobal point-aware, or "rotated" in-place.
*
* @param start
* @param end
* @param r
* @returns
*/
const createDiamondArc = (start: GlobalPoint, end: GlobalPoint, r: number) => {
const c = point((start[0] + end[0]) / 2, start[1]);
return arc(
c,
r,
radians(Math.asin((left[1] - c[1]) / r)),
radians(Math.asin((right[1] - c[1]) / r)),
radians(Math.asin((start[1] - c[1]) / r)),
radians(Math.asin((end[1] - c[1]) / r)),
);
};
/**
* Returns the distance of a point and the provided diamond element, accounting
* for roundness and rotation
*
* @param element The diamond element
* @param p The point to consider
* @returns The eucledian distance to the outline of the diamond
*/
export const distanceToDiamondElement = (
element: ExcalidrawDiamondElement,
p: GlobalPoint,
@ -151,31 +184,19 @@ export const distanceToDiamondElement = (
Math.min(element.width, element.height),
element,
);
const rotatedPoint = pointRotateRads(p, center, element.angle);
const top = pointRotateRads<GlobalPoint>(
// Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(p, center, radians(-element.angle));
const [top, right, bottom, left]: 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);
];
const topRight = createDiamondSide(segment(top, right), roundness);
const bottomRight = createDiamondSide(segment(right, bottom), roundness);
const bottomLeft = createDiamondSide(segment(bottom, left), roundness);
const topLeft = createDiamondSide(segment(left, top), roundness);
return Math.min(
...[
@ -184,16 +205,24 @@ export const distanceToDiamondElement = (
),
...(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),
createDiamondArc(topLeft[1], topRight[0], roundness),
createDiamondArc(topRight[1], bottomRight[0], roundness),
createDiamondArc(bottomRight[1], bottomLeft[0], roundness),
createDiamondArc(bottomLeft[1], topLeft[0], roundness),
].map((a) => arcDistanceFromPoint(a, rotatedPoint))
: []),
],
);
};
/**
* Returns the distance of a point and the provided ellipse element, accounting
* for roundness and rotation
*
* @param element The ellipse element
* @param p The point to consider
* @returns The eucledian distance to the outline of the ellipse
*/
export const distanceToEllipseElement = (
element: ExcalidrawEllipseElement,
p: GlobalPoint,
@ -203,6 +232,7 @@ export const distanceToEllipseElement = (
element.y + element.height / 2,
);
return ellipseDistanceFromPoint(
// Instead of rotating the ellipse, rotate the point to the inverse angle
pointRotateRads(p, center, radians(-element.angle)),
ellipse(center, element.width / 2, element.height / 2),
);

View file

@ -1,9 +1,8 @@
import { updateBoundElements } from "./binding";
import type { Bounds } from "./bounds";
import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import type { NonDeletedExcalidrawElement } from "./types";
import type { Bounds, NonDeletedExcalidrawElement } from "./types";
import type {
AppState,
NormalizedZoomValue,

View file

@ -13,8 +13,8 @@ import {
radiansToDegrees,
triangleIncludesPoint,
} from "../../math";
import { getCenterForBounds, type Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types";
import { getCenterForBounds } from "./bounds";
import type { Bounds, ExcalidrawBindableElement } from "./types";
export const HEADING_RIGHT = [1, 0] as Heading;
export const HEADING_DOWN = [0, 1] as Heading;

View file

@ -19,9 +19,9 @@ export {
getElementAbsoluteCoords,
getElementBounds,
getCommonBounds,
getDiamondPoints,
getClosestElementBounds,
} from "./bounds";
export { getDiamondPoints } from "../scene/Shape";
export {
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,

View file

@ -10,9 +10,9 @@ import type {
OrderedExcalidrawElement,
FixedPointBinding,
SceneElementsMap,
Bounds,
} from "./types";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import type { Bounds } from "./bounds";
import { getElementPointsCoords, getMinMaxXYFromCurvePathOps } from "./bounds";
import type {
AppState,

View file

@ -3,6 +3,7 @@ import type {
PointerType,
NonDeletedExcalidrawElement,
ElementsMap,
Bounds,
} from "./types";
import type {
@ -17,7 +18,6 @@ import {
canResizeFromSides,
} from "./transformHandles";
import type { AppState, Device, Zoom } from "../types";
import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import { SIDE_RESIZING_THRESHOLD } from "../constants";
import { isLinearElement } from "./typeChecks";

View file

@ -24,7 +24,6 @@ import {
getGlobalFixedPointForBindableElement,
snapToMid,
} from "./binding";
import type { Bounds } from "./bounds";
import { distanceToBindableElement } from "./distance";
import type { Heading } from "./heading";
import {
@ -40,6 +39,7 @@ import type { ElementUpdate } from "./mutateElement";
import { mutateElement } from "./mutateElement";
import { isBindableElement, isRectanguloidElement } from "./typeChecks";
import type {
Bounds,
ExcalidrawElbowArrowElement,
NonDeletedSceneElementsMap,
SceneElementsMap,

View file

@ -1,11 +1,11 @@
import type {
Bounds,
ElementsMap,
ExcalidrawElement,
NonDeletedExcalidrawElement,
PointerType,
} from "./types";
import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
import {

View file

@ -2,7 +2,6 @@ import { ROUNDNESS } from "../constants";
import type { ElementOrToolType } from "../types";
import type { MarkNonNullable } from "../utility-types";
import { assertNever } from "../utils";
import type { Bounds } from "./bounds";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
@ -26,6 +25,7 @@ import type {
PointBinding,
FixedPointBinding,
ExcalidrawFlowchartNodeElement,
Bounds,
} from "./types";
export const isInitializedImageElement = (

View file

@ -373,3 +373,13 @@ export type NonDeletedSceneElementsMap = Map<
export type ElementsMapOrArray =
| readonly ExcalidrawElement[]
| Readonly<ElementsMap>;
/**
* Axis-aligned bounding box (i.e. no rotation)
*/
export type Bounds = readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
];