Compare commits

...

30 Commits

Author SHA1 Message Date
Mark Tolmacs
6919373c63
Fix lint errors 2025-05-20 09:28:52 +02:00
Mark Tolmacs
71e13c363e
Tidy up 2025-05-20 09:28:52 +02:00
Mark Tolmacs
83abf2cd94
Clean up @excalidraw/math a bit 2025-05-20 09:28:52 +02:00
Mark Tolmacs
02b4fa1ca7
Fixing mid point calculation precision 2025-05-20 09:28:52 +02:00
Mark Tolmacs
3363e0e289
Refactor shape functions and fix curved half point handle on linear 2025-05-20 09:28:52 +02:00
Mark Tolmacs
076b1e0e31
Remove comment 2025-05-20 09:27:42 +02:00
Mark Tolmacs
6e06fe9fda
Revert "State"
This reverts commit 4ba1bdaead0d74afea3c151efedeb59dfddf24de.
2025-05-20 09:27:42 +02:00
Mark Tolmacs
6ab3b6c029
State 2025-05-20 09:27:42 +02:00
Mark Tolmacs
37ca66044e
Do not connect in outer zoom levels 2025-05-20 09:27:42 +02:00
Mark Tolmacs
cc82cc9671
Simplify maxGap and binding 2025-05-20 09:27:42 +02:00
Mark Tolmacs
d214404244
Fix circular reference 2025-05-20 09:27:42 +02:00
Mark Tolmacs
0c76d6b681
Fix lint 2025-05-20 09:27:42 +02:00
Mark Tolmacs
b63e285f93
Fix tests 2025-05-20 09:27:42 +02:00
Mark Tolmacs
94ed8313f4
Fix no roundness collision shapes 2025-05-20 09:27:42 +02:00
Mark Tolmacs
0050a856e5
Rectangle 2025-05-20 09:27:42 +02:00
Mark Tolmacs
bb4d9649a6
Diamond collision 2025-05-20 09:27:42 +02:00
Mark Tolmacs
14817c1b2d
Refactor 2025-05-20 09:27:42 +02:00
Mark Tolmacs
d681869c4c
Restore original 2025-05-20 09:27:42 +02:00
Mark Tolmacs
ec255cbe53
Fix snapping distance at mid points 2025-05-20 09:27:42 +02:00
Mark Tolmacs
d5af9421f0
Fix tests
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-20 09:27:42 +02:00
Mark Tolmacs
795a5c16c8
Fix tests
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-20 09:27:42 +02:00
Mark Tolmacs
8469c6670a
Inverted polygon now works just as well for hit testing
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-20 09:27:42 +02:00
Mark Tolmacs
6c93d6e997
[skip ci] inverted polygon hit test
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-20 09:27:42 +02:00
Mark Tolmacs
db3e5c63ef
Fixing tests 2025-05-20 09:27:42 +02:00
Mark Tolmacs
508d4c3681
Fix condition where linear and freedraw cannot be unselected 2025-05-20 09:27:42 +02:00
Mark Tolmacs
2082ef149c
Linear distance calc
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-20 09:27:42 +02:00
Mark Tolmacs
3b1c6444e2
Refactor
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-20 09:27:42 +02:00
Mark Tolmacs
cd3ca3b4ca
Use roughjs to generate the line and freedraw shapes for collision 2025-05-20 09:27:42 +02:00
Mark Tolmacs
14a0cd3a97
Fast fail path for hit testing
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-20 09:27:42 +02:00
Mark Tolmacs
0c5d3850d0
Precise hit testing
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-20 09:27:42 +02:00
57 changed files with 1719 additions and 1781 deletions

View File

@ -1,95 +0,0 @@
import { RoughGenerator } from "roughjs/bin/generator";
import { COLOR_PALETTE } from "@excalidraw/common";
import type {
AppState,
EmbedsValidationStatus,
} from "@excalidraw/excalidraw/types";
import type {
ElementShape,
ElementShapes,
} from "@excalidraw/excalidraw/scene/types";
import { _generateElementShape } from "./Shape";
import { elementWithCanvasCache } from "./renderElement";
import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types";
import type { Drawable } from "roughjs/bin/core";
export class ShapeCache {
private static rg = new RoughGenerator();
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
/**
* Retrieves shape from cache if available. Use this only if shape
* is optional and you have a fallback in case it's not cached.
*/
public static get = <T extends ExcalidrawElement>(element: T) => {
return ShapeCache.cache.get(
element,
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] | undefined
: ElementShape | undefined;
};
public static set = <T extends ExcalidrawElement>(
element: T,
shape: T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable,
) => ShapeCache.cache.set(element, shape);
public static delete = (element: ExcalidrawElement) =>
ShapeCache.cache.delete(element);
public static destroy = () => {
ShapeCache.cache = new WeakMap();
};
/**
* Generates & caches shape for element if not already cached, otherwise
* returns cached shape.
*/
public static generateElementShape = <
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
>(
element: T,
renderConfig: {
isExporting: boolean;
canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus;
} | null,
) => {
// when exporting, always regenerated to guarantee the latest shape
const cachedShape = renderConfig?.isExporting
? undefined
: ShapeCache.get(element);
// `null` indicates no rc shape applicable for this element type,
// but it's considered a valid cache value (= do not regenerate)
if (cachedShape !== undefined) {
return cachedShape;
}
elementWithCanvasCache.delete(element);
const shape = _generateElementShape(
element,
ShapeCache.rg,
renderConfig || {
isExporting: false,
canvasBackgroundColor: COLOR_PALETTE.white,
embedsValidationStatus: null,
},
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable | null;
ShapeCache.cache.set(element, shape);
return shape;
};
}

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";
@ -39,9 +37,10 @@ import {
getCenterForBounds,
getElementBounds,
doBoundsIntersect,
aabbForElement,
} from "./bounds";
import { intersectElementWithLineSegment } from "./collision";
import { distanceToBindableElement } from "./distance";
import { distanceToElement } from "./distance";
import {
headingForPointFromElement,
headingIsHorizontal,
@ -63,7 +62,6 @@ import {
isTextElement,
} from "./typeChecks";
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
import { updateElbowArrowPoints } from "./elbowArrow";
import type { Scene } from "./Scene";
@ -109,7 +107,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const BINDING_HIGHLIGHT_OFFSET = 4;
const getNonDeletedElements = (
scene: Scene,
@ -441,22 +438,13 @@ export const maybeBindLinearElement = (
const normalizePointBinding = (
binding: { focus: number; gap: number },
hoveredElement: ExcalidrawBindableElement,
) => {
let gap = binding.gap;
const maxGap = maxBindingGap(
hoveredElement,
hoveredElement.width,
hoveredElement.height,
);
if (gap > maxGap) {
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
}
return {
...binding,
gap,
};
};
) => ({
...binding,
gap: Math.min(
binding.gap,
maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height),
),
});
export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
@ -704,7 +692,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 +886,7 @@ const getDistanceForBinding = (
bindableElement: ExcalidrawBindableElement,
zoom?: AppState["zoom"],
) => {
const distance = distanceToBindableElement(bindableElement, point);
const distance = distanceToElement(bindableElement, point);
const bindDistance = maxBindingGap(
bindableElement,
bindableElement.width,
@ -961,6 +949,7 @@ export const bindPointToSnapToElementOutline = (
otherPoint,
),
),
FIXED_BINDING_DISTANCE,
)[0];
} else {
intersection = intersectElementWithLineSegment(
@ -991,24 +980,7 @@ export const bindPointToSnapToElementOutline = (
return edgePoint;
}
if (elbowed) {
const scalar =
pointDistanceSq(edgePoint, center) -
pointDistanceSq(intersection, center) >
0
? FIXED_BINDING_DISTANCE
: -FIXED_BINDING_DISTANCE;
return pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(edgePoint, intersection)),
scalar,
),
intersection,
);
}
return edgePoint;
return elbowed ? intersection : edgePoint;
};
export const avoidRectangularCorner = (
@ -1119,55 +1091,83 @@ export const snapToMid = (
// snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance
const verticalThrehsold = clamp(tolerance * height, 5, 80);
const horizontalThrehsold = clamp(tolerance * width, 5, 80);
const verticalThreshold = clamp(tolerance * height, 5, 80);
const horizontalThreshold = clamp(tolerance * width, 5, 80);
if (
nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold &&
nonRotated[1] < center[1] + verticalThrehsold
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
// LEFT
return pointRotateRads(
const otherPoint = pointRotateRads<GlobalPoint>(
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
center,
angle,
);
return (
intersectElementWithLineSegment(
element,
lineSegment(center, otherPoint),
FIXED_BINDING_DISTANCE,
)[0] ?? otherPoint
);
} else if (
nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold &&
nonRotated[0] < center[0] + horizontalThrehsold
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
// TOP
return pointRotateRads(
const otherPoint = pointRotateRads<GlobalPoint>(
pointFrom(center[0], y - FIXED_BINDING_DISTANCE),
center,
angle,
);
return (
intersectElementWithLineSegment(
element,
lineSegment(center, otherPoint),
FIXED_BINDING_DISTANCE,
)[0] ?? otherPoint
);
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold &&
nonRotated[1] < center[1] + verticalThrehsold
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
// RIGHT
return pointRotateRads(
const otherPoint = pointRotateRads<GlobalPoint>(
pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]),
center,
angle,
);
return (
intersectElementWithLineSegment(
element,
lineSegment(center, otherPoint),
FIXED_BINDING_DISTANCE,
)[0] ?? otherPoint
);
} else if (
nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold &&
nonRotated[0] < center[0] + horizontalThrehsold
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
// DOWN
return pointRotateRads(
const otherPoint = pointRotateRads<GlobalPoint>(
pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE),
center,
angle,
);
return (
intersectElementWithLineSegment(
element,
lineSegment(center, otherPoint),
FIXED_BINDING_DISTANCE,
)[0] ?? otherPoint
);
} else if (element.type === "diamond") {
const distance = FIXED_BINDING_DISTANCE - 1;
const distance = FIXED_BINDING_DISTANCE;
const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + height / 4 - distance,
@ -1184,27 +1184,28 @@ export const snapToMid = (
x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance,
);
if (
pointDistance(topLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topLeft, center, angle);
}
if (
pointDistance(topRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topRight, center, angle);
}
if (
pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(bottomLeft, center, angle);
}
if (
pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(bottomRight, center, angle);
}
@ -1548,14 +1549,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 = (
@ -1575,7 +1584,7 @@ export const maxBindingGap = (
// bigger bindable boundary for bigger elements
Math.min(0.25 * smallerDimension, 32),
// keep in sync with the zoomed highlight
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE,
);
};

View File

@ -2,6 +2,7 @@ import rough from "roughjs/bin/rough";
import {
arrayToMap,
elementCenterPoint,
invariant,
rescalePoints,
sizeOf,
@ -33,8 +34,7 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import { generateRoughOptions } from "./Shape";
import { ShapeCache } from "./ShapeCache";
import { generateRoughOptions, getElementShape, ShapeCache } from "./shape";
import { LinearElementEditor } from "./linearElementEditor";
import { getBoundTextElement, getContainerElement } from "./textElement";
import {
@ -45,8 +45,6 @@ import {
isTextElement,
} from "./typeChecks";
import { getElementShape } from "./shapes";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
@ -1146,3 +1144,67 @@ export const doBoundsIntersect = (
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
};
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
p: P,
bounds: Bounds,
): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
/**
* Get the axis-aligned bounding box for a given element
*/
export const aabbForElement = (
element: Readonly<ExcalidrawElement>,
offset?: [number, number, number, number],
) => {
const bbox = {
minX: element.x,
minY: element.y,
maxX: element.x + element.width,
maxY: element.y + element.height,
midX: element.x + element.width / 2,
midY: element.y + element.height / 2,
};
const center = elementCenterPoint(element);
const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.minY),
center,
element.angle,
);
const [topRightX, topRightY] = pointRotateRads(
pointFrom(bbox.maxX, bbox.minY),
center,
element.angle,
);
const [bottomRightX, bottomRightY] = pointRotateRads(
pointFrom(bbox.maxX, bbox.maxY),
center,
element.angle,
);
const [bottomLeftX, bottomLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.maxY),
center,
element.angle,
);
const bounds = [
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
] as Bounds;
if (offset) {
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
return [
bounds[0] - leftOffset,
bounds[1] - topOffset,
bounds[2] + rightOffset,
bounds[3] + downOffset,
] as Bounds;
}
return bounds;
};

View File

@ -1,52 +1,67 @@
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
import {
isTransparent,
elementCenterPoint,
arrayToMap,
} from "@excalidraw/common";
import {
curveIntersectLineSegment,
isCurve,
isLineSegment,
isPointWithinBounds,
line,
lineSegment,
lineSegmentIntersectionPoints,
pointFrom,
pointFromVector,
pointRotateRads,
pointsEqual,
vectorFromPoint,
vectorNormalize,
vectorScale,
} from "@excalidraw/math";
import {
ellipse,
ellipseLineIntersectionPoints,
ellipseSegmentInterceptPoints,
} from "@excalidraw/math/ellipse";
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
import type {
Curve,
GlobalPoint,
LineSegment,
LocalPoint,
Polygon,
Radians,
} from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { getBoundTextShape, isPathALoop } from "./shapes";
import { getElementBounds } from "./bounds";
import {
hasBoundTextElement,
isFreeDrawElement,
isIframeLikeElement,
isImageElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import {
deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement,
isPathALoop,
} from "./utils";
import { getBoundTextElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance";
import type {
ElementsMap,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawRectangleElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
} from "./types";
@ -72,45 +87,49 @@ 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>) => {
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);
}: HitTestArgs) => {
// First check if the element is in the bounding box because it's MUCH faster
// than checking if the point is in the element's shape
let hit = hitElementBoundingBox(
point,
element,
arrayToMap([element]),
threshold,
)
? shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInShape(point, element) ||
isPointOnShape(point, element, threshold)
: isPointOnShape(point, element, threshold)
: false;
// 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 +139,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);
};
/**
@ -173,17 +200,51 @@ export const intersectElementWithLineSegment = (
case "iframe":
case "embeddable":
case "frame":
case "selection":
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}'`);
case "line":
case "freedraw":
case "arrow":
return intersectLinearOrFreeDrawWithLineSegment(element, line);
}
};
const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment<GlobalPoint>,
): GlobalPoint[] => {
const shapes = deconstructLinearOrFreeDrawElement(element);
const intersections: GlobalPoint[] = [];
for (const shape of shapes) {
switch (true) {
case isCurve(shape):
intersections.push(
...curveIntersectLineSegment(shape as Curve<GlobalPoint>, segment),
);
continue;
case isLineSegment(shape):
const point = lineSegmentIntersectionPoints(
segment,
shape as LineSegment<GlobalPoint>,
);
if (point) {
intersections.push(point);
}
continue;
}
}
return intersections;
};
const intersectRectanguloidWithLineSegment = (
element: ExcalidrawRectanguloidElement,
l: LineSegment<GlobalPoint>,
@ -301,8 +362,46 @@ const intersectEllipseWithLineSegment = (
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
return ellipseLineIntersectionPoints(
return ellipseSegmentInterceptPoints(
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
line(rotatedA, rotatedB),
lineSegment(rotatedA, rotatedB),
).map((p) => pointRotateRads(p, center, element.angle));
};
// check if the given point is considered on the given shape's border
const isPointOnShape = (
point: GlobalPoint,
element: ExcalidrawElement,
tolerance = 1,
) => distanceToElement(element, point) <= tolerance;
// check if the given point is considered inside the element's border
export const isPointInShape = (
point: GlobalPoint,
element: ExcalidrawElement,
) => {
if (
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
// There isn't any "inside" for a non-looping path
return false;
}
const [x1, y1, x2, y2] = getElementBounds(element, new Map());
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const otherPoint = pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(point, center, 0.1)),
Math.max(element.width, element.height) * 2,
),
center,
);
const intersector = lineSegment(point, otherPoint);
const intersections = intersectElementWithLineSegment(
element,
intersector,
).filter((item, pos, arr) => arr.indexOf(item) === pos);
return intersections.length % 2 === 1;
};

View File

@ -1,6 +1,8 @@
import {
curvePointDistance,
distanceToLineSegment,
isCurve,
isLineSegment,
pointRotateRads,
} from "@excalidraw/math";
@ -8,25 +10,34 @@ import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
import { elementCenterPoint } from "@excalidraw/common";
import type { GlobalPoint, Radians } from "@excalidraw/math";
import type {
Curve,
GlobalPoint,
LineSegment,
Radians,
} from "@excalidraw/math";
import {
deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement,
} from "./utils";
import type {
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
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 +50,10 @@ export const distanceToBindableElement = (
return distanceToDiamondElement(element, p);
case "ellipse":
return distanceToEllipseElement(element, p);
case "line":
case "arrow":
case "freedraw":
return distanceToLinearOrFreeDraElement(element, p);
}
};
@ -117,3 +132,36 @@ const distanceToEllipseElement = (
ellipse(center, element.width / 2, element.height / 2),
);
};
const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
p: GlobalPoint,
) => {
const shapes = deconstructLinearOrFreeDrawElement(element);
let distance = Infinity;
for (const shape of shapes) {
switch (true) {
case isCurve(shape): {
const d = curvePointDistance(shape as Curve<GlobalPoint>, p);
if (d < distance) {
distance = d;
}
continue;
}
case isLineSegment(shape): {
const d = distanceToLineSegment(p, shape as LineSegment<GlobalPoint>);
if (d < distance) {
distance = d;
}
continue;
}
}
}
return distance;
};

View File

@ -32,7 +32,7 @@ import {
snapToMid,
getHoveredElementForBinding,
} from "./binding";
import { distanceToBindableElement } from "./distance";
import { distanceToElement } from "./distance";
import {
compareHeading,
flipHeading,
@ -52,9 +52,8 @@ import {
type NonDeletedSceneElementsMap,
} from "./types";
import { aabbForElement, pointInsideBounds } from "./shapes";
import { aabbForElement, pointInsideBounds, type Bounds } from "./bounds";
import type { Bounds } from "./bounds";
import type { Heading } from "./heading";
import type {
Arrowhead,
@ -2234,8 +2233,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 +2255,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

@ -21,7 +21,6 @@ import {
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement } from "./mutateElement";
import { newArrowElement, newElement } from "./newElement";
import { aabbForElement } from "./shapes";
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
import {
isBindableElement,
@ -38,6 +37,7 @@ import {
type Ordered,
type OrderedExcalidrawElement,
} from "./types";
import { aabbForElement } from "./bounds";
import type { Scene } from "./Scene";

View File

@ -102,9 +102,7 @@ export * from "./resizeElements";
export * from "./resizeTest";
export * from "./Scene";
export * from "./selection";
export * from "./Shape";
export * from "./ShapeCache";
export * from "./shapes";
export * from "./shape";
export * from "./showSelectedShapeActions";
export * from "./sizeHelpers";
export * from "./sortElements";

View File

@ -7,6 +7,10 @@ import {
type LocalPoint,
pointDistance,
vectorFromPoint,
isCurve,
isLineSegment,
curveLength,
curvePointAtLength,
} from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape";
@ -20,9 +24,14 @@ import {
tupleToCoors,
} from "@excalidraw/common";
import type { Store } from "@excalidraw/element";
import {
deconstructLinearOrFreeDrawElement,
isPathALoop,
ShapeCache,
type Store,
} from "@excalidraw/element";
import type { Radians } from "@excalidraw/math";
import type { Curve, Radians } from "@excalidraw/math";
import type {
AppState,
@ -55,16 +64,6 @@ import {
isFixedPointBinding,
} from "./typeChecks";
import { ShapeCache } from "./ShapeCache";
import {
isPathALoop,
getBezierCurveLength,
getControlPointsForBezierCurve,
mapIntervalToBezierT,
getBezierXY,
} from "./shapes";
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
import type { Scene } from "./Scene";
@ -567,10 +566,7 @@ export class LinearElementEditor {
}
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element,
points[index],
points[index + 1],
index + 1,
elementsMap,
);
midpoints.push(segmentMidPoint);
index++;
@ -672,7 +668,14 @@ export class LinearElementEditor {
let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint);
const segments = deconstructLinearOrFreeDrawElement(element);
invariant(
segments.length >= index,
"Invalid segment index while calculating segment length",
);
distance = curveLength(segments[index] as Curve<GlobalPoint>);
}
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
@ -680,39 +683,39 @@ export class LinearElementEditor {
static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>,
startPoint: GlobalPoint,
endPoint: GlobalPoint,
endPointIndex: number,
elementsMap: ElementsMap,
index: number,
): GlobalPoint {
let segmentMidPoint = pointCenter(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
const controlPoints = getControlPointsForBezierCurve(
element,
element.points[endPointIndex],
if (isElbowArrow(element)) {
invariant(
element.points.length >= index,
"Invalid segment index while calculating elbow arrow mid point",
);
if (controlPoints) {
const t = mapIntervalToBezierT(
element,
element.points[endPointIndex],
0.5,
);
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
element,
getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
),
elementsMap,
);
}
const p = pointCenter(element.points[index - 1], element.points[index]);
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
}
return segmentMidPoint;
const segments = deconstructLinearOrFreeDrawElement(element);
invariant(
segments.length >= index,
"Invalid segment index while calculating mid point",
);
const shape = segments[index - 1];
switch (true) {
case isCurve(shape):
return curvePointAtLength(shape as Curve<GlobalPoint>, 0.5);
case isLineSegment(shape):
return pointCenter(shape[0] as GlobalPoint, shape[1] as GlobalPoint);
}
invariant(
false,
`Invalid segment type while calculating mid point ${shape}`,
);
}
static getSegmentMidPointIndex(
@ -1592,10 +1595,7 @@ export class LinearElementEditor {
const index = element.points.length / 2 - 1;
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
points[index],
points[index + 1],
index + 1,
elementsMap,
);
x = midSegmentMidpoint[0] - boundTextElement.width / 2;

View File

@ -8,11 +8,10 @@ import type { Radians } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache";
import { updateElbowArrowPoints } from "./elbowArrow";
import { isElbowArrow } from "./typeChecks";
import { ShapeCache } from "./shape";
import type {
ElementsMap,

View File

@ -54,9 +54,9 @@ import {
isImageElement,
} from "./typeChecks";
import { getContainingFrame } from "./frame";
import { getCornerRadius } from "./shapes";
import { ShapeCache } from "./ShapeCache";
import { getCornerRadius } from "./utils";
import { ShapeCache } from "./shape";
import type {
ExcalidrawElement,

View File

@ -1,12 +1,43 @@
import { simplify } from "points-on-curve";
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
import {
getClosedCurveShape,
getCurveShape,
getEllipseShape,
getFreedrawShape,
getPolygonShape,
} from "@excalidraw/utils/shape";
import {
pointFrom,
pointDistance,
type LocalPoint,
pointRotateRads,
} from "@excalidraw/math";
import {
ROUGHNESS,
isTransparent,
assertNever,
COLOR_PALETTE,
} from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator";
import type { GlobalPoint, Radians } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types";
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
import type {
AppState,
EmbedsValidationStatus,
} from "@excalidraw/excalidraw/types";
import type {
ElementShape,
ElementShapes,
} from "@excalidraw/excalidraw/scene/types";
import type { GeometricShape } from "@excalidraw/utils/shape";
import {
isElbowArrow,
@ -15,12 +46,19 @@ import {
isIframeLikeElement,
isLinearElement,
} from "./typeChecks";
import { getCornerRadius, isPathALoop } from "./shapes";
import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons";
import { generateFreeDrawShape } from "./renderElement";
import { getArrowheadPoints, getDiamondPoints } from "./bounds";
import { elementWithCanvasCache, generateFreeDrawShape } from "./renderElement";
import {
getArrowheadPoints,
getDiamondPoints,
getElementAbsoluteCoords,
getElementBounds,
} from "./bounds";
import { shouldTestInside } from "./collision";
import { getCornerRadius, isPathALoop } from "./utils";
import type {
ExcalidrawElement,
@ -28,10 +66,11 @@ import type {
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
ElementsMap,
} from "./types";
import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@ -303,6 +342,125 @@ const getArrowheadShapes = (
}
};
export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
) => {
const generator = new RoughGenerator();
const options: Options = {
seed: element.seed,
disableMultiStroke: true,
disableMultiStrokeFill: true,
roughness: 0,
preserveVertices: true,
};
switch (element.type) {
case "line":
case "arrow": {
// points array can be empty in the beginning, so it is important to add
// initial position to it
const points = element.points.length
? element.points
: [pointFrom<LocalPoint>(0, 0)];
const [x1, y1, x2, y2] = getElementBounds(
{
...element,
angle: 0 as Radians,
},
new Map(),
);
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
if (isElbowArrow(element)) {
return generator.path(generateElbowArrowShape(points, 16), options)
.sets[0].ops;
} else if (!element.roundness) {
return points.map((point, idx) => {
const p = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return {
op: idx === 0 ? "move" : "lineTo",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
});
}
return generator
.curve(points as unknown as RoughPoint[], options)
.sets[0].ops.slice(0, element.points.length)
.map((op, i, arr) => {
if (i === 0) {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
);
return {
op: "move",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
}
return {
op: "bcurveTo",
data: [
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
center,
element.angle,
),
]
.map((p) =>
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
)
.flat(),
};
});
}
case "freedraw": {
if (element.points.length < 2) {
return [];
}
const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
0.75,
);
return generator
.curve(simplifiedPoints as [number, number][], options)
.sets[0].ops.slice(0, element.points.length);
}
}
};
/**
* Generates the roughjs shape for given element.
*
@ -611,3 +769,134 @@ const generateElbowArrowShape = (
return d.join(" ");
};
/**
* get the pure geometric shape of an excalidraw elementw
* which is then used for hit detection
*/
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape<Point> => {
switch (element.type) {
case "rectangle":
case "diamond":
case "frame":
case "magicframe":
case "embeddable":
case "image":
case "iframe":
case "text":
case "selection":
return getPolygonShape(element);
case "arrow":
case "line": {
const roughShape =
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return shouldTestInside(element)
? getClosedCurveShape<Point>(
element,
roughShape,
pointFrom<Point>(element.x, element.y),
element.angle,
pointFrom(cx, cy),
)
: getCurveShape<Point>(
roughShape,
pointFrom<Point>(element.x, element.y),
element.angle,
pointFrom(cx, cy),
);
}
case "ellipse":
return getEllipseShape(element);
case "freedraw": {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return getFreedrawShape(
element,
pointFrom(cx, cy),
shouldTestInside(element),
);
}
}
};
export class ShapeCache {
private static rg = new RoughGenerator();
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
/**
* Retrieves shape from cache if available. Use this only if shape
* is optional and you have a fallback in case it's not cached.
*/
public static get = <T extends ExcalidrawElement>(element: T) => {
return ShapeCache.cache.get(
element,
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] | undefined
: ElementShape | undefined;
};
public static set = <T extends ExcalidrawElement>(
element: T,
shape: T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable,
) => ShapeCache.cache.set(element, shape);
public static delete = (element: ExcalidrawElement) =>
ShapeCache.cache.delete(element);
public static destroy = () => {
ShapeCache.cache = new WeakMap();
};
/**
* Generates & caches shape for element if not already cached, otherwise
* returns cached shape.
*/
public static generateElementShape = <
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
>(
element: T,
renderConfig: {
isExporting: boolean;
canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus;
} | null,
) => {
// when exporting, always regenerated to guarantee the latest shape
const cachedShape = renderConfig?.isExporting
? undefined
: ShapeCache.get(element);
// `null` indicates no rc shape applicable for this element type,
// but it's considered a valid cache value (= do not regenerate)
if (cachedShape !== undefined) {
return cachedShape;
}
elementWithCanvasCache.delete(element);
const shape = _generateElementShape(
element,
ShapeCache.rg,
renderConfig || {
isExporting: false,
canvasBackgroundColor: COLOR_PALETTE.white,
embedsValidationStatus: null,
},
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable | null;
ShapeCache.cache.set(element, shape);
return shape;
};
}

View File

@ -1,398 +0,0 @@
import {
DEFAULT_ADAPTIVE_RADIUS,
DEFAULT_PROPORTIONAL_RADIUS,
LINE_CONFIRM_THRESHOLD,
ROUNDNESS,
invariant,
elementCenterPoint,
} from "@excalidraw/common";
import {
isPoint,
pointFrom,
pointDistance,
pointFromPair,
pointRotateRads,
pointsEqual,
type GlobalPoint,
type LocalPoint,
} from "@excalidraw/math";
import {
getClosedCurveShape,
getCurvePathOps,
getCurveShape,
getEllipseShape,
getFreedrawShape,
getPolygonShape,
type GeometricShape,
} from "@excalidraw/utils/shape";
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
import { shouldTestInside } from "./collision";
import { LinearElementEditor } from "./linearElementEditor";
import { getBoundTextElement } from "./textElement";
import { ShapeCache } from "./ShapeCache";
import { getElementAbsoluteCoords, type Bounds } from "./bounds";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
} from "./types";
/**
* get the pure geometric shape of an excalidraw elementw
* which is then used for hit detection
*/
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape<Point> => {
switch (element.type) {
case "rectangle":
case "diamond":
case "frame":
case "magicframe":
case "embeddable":
case "image":
case "iframe":
case "text":
case "selection":
return getPolygonShape(element);
case "arrow":
case "line": {
const roughShape =
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return shouldTestInside(element)
? getClosedCurveShape<Point>(
element,
roughShape,
pointFrom<Point>(element.x, element.y),
element.angle,
pointFrom(cx, cy),
)
: getCurveShape<Point>(
roughShape,
pointFrom<Point>(element.x, element.y),
element.angle,
pointFrom(cx, cy),
);
}
case "ellipse":
return getEllipseShape(element);
case "freedraw": {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return getFreedrawShape(
element,
pointFrom(cx, cy),
shouldTestInside(element),
);
}
}
};
export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape<Point> | null => {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
if (element.type === "arrow") {
return getElementShape(
{
...boundTextElement,
// 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,
boundTextElement,
elementsMap,
),
},
elementsMap,
);
}
return getElementShape(boundTextElement, elementsMap);
}
return null;
};
export const getControlPointsForBezierCurve = <
P extends GlobalPoint | LocalPoint,
>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const shape = ShapeCache.generateElementShape(element, null);
if (!shape) {
return null;
}
const ops = getCurvePathOps(shape[0]);
let currentP = pointFrom<P>(0, 0);
let index = 0;
let minDistance = Infinity;
let controlPoints: P[] | null = null;
while (index < ops.length) {
const { op, data } = ops[index];
if (op === "move") {
invariant(
isPoint(data),
"The returned ops is not compatible with a point",
);
currentP = pointFromPair(data);
}
if (op === "bcurveTo") {
const p0 = currentP;
const p1 = pointFrom<P>(data[0], data[1]);
const p2 = pointFrom<P>(data[2], data[3]);
const p3 = pointFrom<P>(data[4], data[5]);
const distance = pointDistance(p3, endPoint);
if (distance < minDistance) {
minDistance = distance;
controlPoints = [p0, p1, p2, p3];
}
currentP = p3;
}
index++;
}
return controlPoints;
};
export const getBezierXY = <P extends GlobalPoint | LocalPoint>(
p0: P,
p1: P,
p2: P,
p3: P,
t: number,
): P => {
const equation = (t: number, idx: number) =>
Math.pow(1 - t, 3) * p3[idx] +
3 * t * Math.pow(1 - t, 2) * p2[idx] +
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
p0[idx] * Math.pow(t, 3);
const tx = equation(t, 0);
const ty = equation(t, 1);
return pointFrom(tx, ty);
};
const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const controlPoints: P[] = getControlPointsForBezierCurve(element, endPoint)!;
if (!controlPoints) {
return [];
}
const pointsOnCurve: P[] = [];
let t = 1;
// Take 20 points on curve for better accuracy
while (t > 0) {
const p = getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
);
pointsOnCurve.push(pointFrom(p[0], p[1]));
t -= 0.05;
}
if (pointsOnCurve.length) {
if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
pointsOnCurve.push(pointFrom(endPoint[0], endPoint[1]));
}
}
return pointsOnCurve;
};
const getBezierCurveArcLengths = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const arcLengths: number[] = [];
arcLengths[0] = 0;
const points = getPointsInBezierCurve(element, endPoint);
let index = 0;
let distance = 0;
while (index < points.length - 1) {
const segmentDistance = pointDistance(points[index], points[index + 1]);
distance += segmentDistance;
arcLengths.push(distance);
index++;
}
return arcLengths;
};
export const getBezierCurveLength = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const arcLengths = getBezierCurveArcLengths(element, endPoint);
return arcLengths.at(-1) as number;
};
// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
) => {
const arcLengths = getBezierCurveArcLengths(element, endPoint);
const pointsCount = arcLengths.length - 1;
const curveLength = arcLengths.at(-1) as number;
const targetLength = interval * curveLength;
let low = 0;
let high = pointsCount;
let index = 0;
// Doing a binary search to find the largest length that is less than the target length
while (low < high) {
index = Math.floor(low + (high - low) / 2);
if (arcLengths[index] < targetLength) {
low = index + 1;
} else {
high = index;
}
}
if (arcLengths[index] > targetLength) {
index--;
}
if (arcLengths[index] === targetLength) {
return index / pointsCount;
}
return (
1 -
(index +
(targetLength - arcLengths[index]) /
(arcLengths[index + 1] - arcLengths[index])) /
pointsCount
);
};
/**
* Get the axis-aligned bounding box for a given element
*/
export const aabbForElement = (
element: Readonly<ExcalidrawElement>,
offset?: [number, number, number, number],
) => {
const bbox = {
minX: element.x,
minY: element.y,
maxX: element.x + element.width,
maxY: element.y + element.height,
midX: element.x + element.width / 2,
midY: element.y + element.height / 2,
};
const center = elementCenterPoint(element);
const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.minY),
center,
element.angle,
);
const [topRightX, topRightY] = pointRotateRads(
pointFrom(bbox.maxX, bbox.minY),
center,
element.angle,
);
const [bottomRightX, bottomRightY] = pointRotateRads(
pointFrom(bbox.maxX, bbox.maxY),
center,
element.angle,
);
const [bottomLeftX, bottomLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.maxY),
center,
element.angle,
);
const bounds = [
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
] as Bounds;
if (offset) {
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
return [
bounds[0] - leftOffset,
bounds[1] - topOffset,
bounds[2] + rightOffset,
bounds[3] + downOffset,
] as Bounds;
}
return bounds;
};
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
p: P,
bounds: Bounds,
): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
pointInsideBounds(pointFrom(a[0], a[1]), b) ||
pointInsideBounds(pointFrom(a[2], a[1]), b) ||
pointInsideBounds(pointFrom(a[2], a[3]), b) ||
pointInsideBounds(pointFrom(a[0], a[3]), b) ||
pointInsideBounds(pointFrom(b[0], b[1]), a) ||
pointInsideBounds(pointFrom(b[2], b[1]), a) ||
pointInsideBounds(pointFrom(b[2], b[3]), a) ||
pointInsideBounds(pointFrom(b[0], b[3]), a);
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
if (
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
element.roundness?.type === ROUNDNESS.LEGACY
) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
if (x <= CUTOFF_SIZE) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
return fixedRadiusSize;
}
return 0;
};
// Checks if the first and last point are close enough
// to be considered a loop
export const isPathALoop = (
points: ExcalidrawLinearElement["points"],
/** supply if you want the loop detection to account for current zoom */
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
): boolean => {
if (points.length >= 3) {
const [first, last] = [points[0], points[points.length - 1]];
const distance = pointDistance(first, last);
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
// really close we make the threshold smaller, and vice versa.
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
}
return false;
};

View File

@ -326,10 +326,7 @@ export const getContainerCenter = (
if (!midSegmentMidpoint) {
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container,
points[index],
points[index + 1],
index + 1,
elementsMap,
);
}
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };

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

@ -1,28 +1,106 @@
import {
DEFAULT_ADAPTIVE_RADIUS,
DEFAULT_PROPORTIONAL_RADIUS,
LINE_CONFIRM_THRESHOLD,
ROUNDNESS,
} from "@excalidraw/common";
import {
curve,
curveCatmullRomCubicApproxPoints,
curveOffsetPoints,
lineSegment,
pointDistance,
pointFrom,
pointFromVector,
pointFromArray,
rectangle,
vectorFromPoint,
vectorNormalize,
vectorScale,
type GlobalPoint,
} from "@excalidraw/math";
import { elementCenterPoint } from "@excalidraw/common";
import type { Curve, LineSegment } from "@excalidraw/math";
import { getCornerRadius } from "./shapes";
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
import { getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./shape";
import type {
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
} from "./types";
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
): (Curve<GlobalPoint> | LineSegment<GlobalPoint>)[] {
const ops = generateLinearCollisionShape(element) as {
op: string;
data: number[];
}[];
const components = [];
for (let idx = 0; idx < ops.length; idx += 1) {
const op = ops[idx];
const prevPoint =
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
switch (op.op) {
case "move":
continue;
case "lineTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
components.push(
lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
),
);
continue;
case "bcurveTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
components.push(
curve<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
),
);
continue;
default: {
console.error("Unknown op type", op.op);
}
}
}
return components;
}
/**
* Get the building components of a rectanguloid element in the form of
* line segments and curves.
@ -35,175 +113,123 @@ export function deconstructRectanguloidElement(
element: ExcalidrawRectanguloidElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const roundness = getCornerRadius(
let radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
if (roundness <= 0) {
const r = rectangle(
pointFrom(element.x - offset, element.y - offset),
pointFrom(
element.x + element.width + offset,
element.y + element.height + offset,
),
);
const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
);
const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
);
const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
);
const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
);
const sides = [top, right, bottom, left];
return [sides, []];
if (radius === 0) {
radius = 0.01;
}
const center = elementCenterPoint(element);
const r = rectangle(
pointFrom(element.x, element.y),
pointFrom(element.x + element.width, element.y + element.height),
);
const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
);
const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
);
const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
);
const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
);
const offsets = [
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
),
offset,
), // TOP LEFT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
),
offset,
), //TOP RIGHT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
),
offset,
), // BOTTOM RIGHT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
),
offset,
), // BOTTOM LEFT
];
const corners = [
const baseCorners = [
curve(
pointFromVector(offsets[0], left[1]),
pointFromVector(
offsets[0],
pointFrom<GlobalPoint>(
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
),
left[1],
pointFrom<GlobalPoint>(
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
),
pointFromVector(
offsets[0],
pointFrom<GlobalPoint>(
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
),
pointFrom<GlobalPoint>(
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
),
pointFromVector(offsets[0], top[0]),
top[0],
), // TOP LEFT
curve(
pointFromVector(offsets[1], top[1]),
pointFromVector(
offsets[1],
pointFrom<GlobalPoint>(
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
),
top[1],
pointFrom<GlobalPoint>(
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
),
pointFromVector(
offsets[1],
pointFrom<GlobalPoint>(
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
),
pointFrom<GlobalPoint>(
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
),
pointFromVector(offsets[1], right[0]),
right[0],
), // TOP RIGHT
curve(
pointFromVector(offsets[2], right[1]),
pointFromVector(
offsets[2],
pointFrom<GlobalPoint>(
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
),
right[1],
pointFrom<GlobalPoint>(
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
),
pointFromVector(
offsets[2],
pointFrom<GlobalPoint>(
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
),
pointFrom<GlobalPoint>(
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
),
pointFromVector(offsets[2], bottom[1]),
bottom[1],
), // BOTTOM RIGHT
curve(
pointFromVector(offsets[3], bottom[0]),
pointFromVector(
offsets[3],
pointFrom<GlobalPoint>(
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
),
bottom[0],
pointFrom<GlobalPoint>(
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
),
pointFromVector(
offsets[3],
pointFrom<GlobalPoint>(
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
),
pointFrom<GlobalPoint>(
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
),
pointFromVector(offsets[3], left[0]),
left[0],
), // BOTTOM LEFT
];
const corners =
offset > 0
? baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
const sides = [
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
lineSegment<GlobalPoint>(
corners[0][corners[0].length - 1][3],
corners[1][0][0],
),
lineSegment<GlobalPoint>(
corners[1][corners[1].length - 1][3],
corners[2][0][0],
),
lineSegment<GlobalPoint>(
corners[2][corners[2].length - 1][3],
corners[3][0][0],
),
lineSegment<GlobalPoint>(
corners[3][corners[3].length - 1][3],
corners[0][0][0],
),
];
return [sides, corners];
return [sides, corners.flat()];
}
/**
@ -220,40 +246,12 @@ export function deconstructDiamondElement(
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
if (element.roundness?.type == null) {
const [top, right, bottom, left]: GlobalPoint[] = [
pointFrom(element.x + topX, element.y + topY - offset),
pointFrom(element.x + rightX + offset, element.y + rightY),
pointFrom(element.x + bottomX, element.y + bottomY + offset),
pointFrom(element.x + leftX - offset, element.y + leftY),
];
// Create the line segment parts of the diamond
// NOTE: Horizontal and vertical seems to be flipped here
const topRight = lineSegment<GlobalPoint>(
pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius),
pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius),
);
const bottomRight = lineSegment<GlobalPoint>(
pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius),
pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius),
);
const bottomLeft = lineSegment<GlobalPoint>(
pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius),
pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius),
);
const topLeft = lineSegment<GlobalPoint>(
pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius),
pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius),
);
return [[topRight, bottomRight, bottomLeft, topLeft], []];
}
const center = elementCenterPoint(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const [top, right, bottom, left]: GlobalPoint[] = [
pointFrom(element.x + topX, element.y + topY),
@ -262,94 +260,131 @@ export function deconstructDiamondElement(
pointFrom(element.x + leftX, element.y + leftY),
];
const offsets = [
vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT
vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM
vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT
vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP
];
const corners = [
const baseCorners = [
curve(
pointFromVector(
offsets[0],
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
right[1] - horizontalRadius,
),
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
right[1] - horizontalRadius,
),
pointFromVector(offsets[0], right),
pointFromVector(offsets[0], right),
pointFromVector(
offsets[0],
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
right[1] + horizontalRadius,
),
right,
right,
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
right[1] + horizontalRadius,
),
), // RIGHT
curve(
pointFromVector(
offsets[1],
pointFrom<GlobalPoint>(
bottom[0] + verticalRadius,
bottom[1] - horizontalRadius,
),
pointFrom<GlobalPoint>(
bottom[0] + verticalRadius,
bottom[1] - horizontalRadius,
),
pointFromVector(offsets[1], bottom),
pointFromVector(offsets[1], bottom),
pointFromVector(
offsets[1],
pointFrom<GlobalPoint>(
bottom[0] - verticalRadius,
bottom[1] - horizontalRadius,
),
bottom,
bottom,
pointFrom<GlobalPoint>(
bottom[0] - verticalRadius,
bottom[1] - horizontalRadius,
),
), // BOTTOM
curve(
pointFromVector(
offsets[2],
pointFrom<GlobalPoint>(
left[0] + verticalRadius,
left[1] + horizontalRadius,
),
pointFrom<GlobalPoint>(
left[0] + verticalRadius,
left[1] + horizontalRadius,
),
pointFromVector(offsets[2], left),
pointFromVector(offsets[2], left),
pointFromVector(
offsets[2],
pointFrom<GlobalPoint>(
left[0] + verticalRadius,
left[1] - horizontalRadius,
),
left,
left,
pointFrom<GlobalPoint>(
left[0] + verticalRadius,
left[1] - horizontalRadius,
),
), // LEFT
curve(
pointFromVector(
offsets[3],
pointFrom<GlobalPoint>(
top[0] - verticalRadius,
top[1] + horizontalRadius,
),
pointFrom<GlobalPoint>(
top[0] - verticalRadius,
top[1] + horizontalRadius,
),
pointFromVector(offsets[3], top),
pointFromVector(offsets[3], top),
pointFromVector(
offsets[3],
pointFrom<GlobalPoint>(
top[0] + verticalRadius,
top[1] + horizontalRadius,
),
top,
top,
pointFrom<GlobalPoint>(
top[0] + verticalRadius,
top[1] + horizontalRadius,
),
), // TOP
];
const corners =
offset > 0
? baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
const sides = [
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
lineSegment<GlobalPoint>(
corners[0][corners[0].length - 1][3],
corners[1][0][0],
),
lineSegment<GlobalPoint>(
corners[1][corners[1].length - 1][3],
corners[2][0][0],
),
lineSegment<GlobalPoint>(
corners[2][corners[2].length - 1][3],
corners[3][0][0],
),
lineSegment<GlobalPoint>(
corners[3][corners[3].length - 1][3],
corners[0][0][0],
),
];
return [sides, corners];
return [sides, corners.flat()];
}
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
if (
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
element.roundness?.type === ROUNDNESS.LEGACY
) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
if (x <= CUTOFF_SIZE) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
return fixedRadiusSize;
}
return 0;
};
// Checks if the first and last point are close enough
// to be considered a loop
export const isPathALoop = (
points: ExcalidrawLinearElement["points"],
/** supply if you want the loop detection to account for current zoom */
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
): boolean => {
if (points.length >= 3) {
const [first, last] = [points[0], points[points.length - 1]];
const distance = pointDistance(first, last);
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
// really close we make the threshold smaller, and vice versa.
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
}
return false;
};

View File

@ -35,6 +35,7 @@ const createAndSelectTwoRectangles = () => {
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
};
@ -52,6 +53,7 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
};
@ -202,6 +204,7 @@ describe("aligning", () => {
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
@ -215,6 +218,7 @@ describe("aligning", () => {
// Add the created group to the current selection
mouse.restorePosition(0, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
};
@ -316,6 +320,7 @@ describe("aligning", () => {
// The second rectangle is already selected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
@ -330,7 +335,7 @@ describe("aligning", () => {
mouse.down();
mouse.up(100, 100);
mouse.restorePosition(200, 200);
mouse.restorePosition(210, 200);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
@ -341,6 +346,7 @@ describe("aligning", () => {
// The second group is already selected because it was the last group created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
};
@ -454,6 +460,7 @@ describe("aligning", () => {
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
@ -466,7 +473,7 @@ describe("aligning", () => {
mouse.up(100, 100);
// Add group to current selection
mouse.restorePosition(0, 0);
mouse.restorePosition(10, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
@ -482,6 +489,7 @@ describe("aligning", () => {
// Select the nested group, the rectangle is already selected
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
};

View File

@ -172,13 +172,13 @@ describe("element binding", () => {
const arrow = UI.createElement("arrow", {
x: 0,
y: 0,
size: 50,
size: 49,
});
expect(arrow.endBinding).toBe(null);
mouse.downAt(50, 50);
mouse.moveTo(51, 0);
mouse.moveTo(57, 0);
mouse.up(0, 0);
// Test sticky connection

View File

@ -346,12 +346,12 @@ describe("Test Linear Elements", () => {
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
[
[
"55.96978",
"47.44233",
"54.27552",
"46.16120",
],
[
"76.08587",
"43.29417",
"76.95494",
"44.56052",
],
]
`);
@ -411,12 +411,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(`
[
[
"105.96978",
"67.44233",
"104.27552",
"66.16120",
],
[
"126.08587",
"63.29417",
"126.95494",
"64.56052",
],
]
`);
@ -727,12 +727,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(`
[
[
"31.88408",
"23.13276",
"29.28349",
"20.91105",
],
[
"77.74793",
"44.57841",
"78.86048",
"46.12277",
],
]
`);
@ -816,12 +816,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(`
[
[
"55.96978",
"47.44233",
"54.27552",
"46.16120",
],
[
"76.08587",
"43.29417",
"76.95494",
"44.56052",
],
]
`);
@ -983,8 +983,8 @@ describe("Test Linear Elements", () => {
);
expect(position).toMatchInlineSnapshot(`
{
"x": "85.82202",
"y": "75.63461",
"x": "86.17305",
"y": "76.11251",
}
`);
});
@ -1262,7 +1262,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0);
mouse.upAt(200, 0);
expect(arrow.width).toBeCloseTo(204, 0);
expect(arrow.width).toBeCloseTo(200, 0);
expect(rect.x).toBe(200);
expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(

View File

@ -510,12 +510,12 @@ describe("arrow element", () => {
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
});
@ -538,11 +538,11 @@ describe("arrow element", () => {
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
});
});
@ -819,7 +819,7 @@ describe("image element", () => {
UI.resize(image, "ne", [40, 0]);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(31, 0);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
const imageWidth = image.width;
const scale = 20 / image.height;
@ -1033,7 +1033,7 @@ describe("multiple selection", () => {
expect(leftBoundArrow.x).toBeCloseTo(-110);
expect(leftBoundArrow.y).toBeCloseTo(50);
expect(leftBoundArrow.width).toBeCloseTo(143, 0);
expect(leftBoundArrow.width).toBeCloseTo(140, 0);
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
expect(leftBoundArrow.angle).toEqual(0);
expect(leftBoundArrow.startBinding).toBeNull();

View File

@ -18,7 +18,6 @@ import {
arrayToMap,
getFontFamilyString,
getShortcutKey,
tupleToCoors,
getLineHeight,
reduceToCommonValue,
} from "@excalidraw/common";
@ -27,9 +26,7 @@ import { getNonDeletedElements } from "@excalidraw/element";
import {
bindLinearElement,
bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding,
updateBoundElements,
} from "@excalidraw/element";
@ -1626,63 +1623,16 @@ export const actionChangeArrowType = register({
-1,
elementsMap,
);
const startHoveredElement =
!newElement.startBinding &&
getHoveredElementForBinding(
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
appState.zoom,
false,
true,
);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
appState.zoom,
false,
true,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
newElement,
startHoveredElement,
"start",
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
newElement,
endHoveredElement,
"end",
)
: endGlobalPoint;
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
app.scene,
);
endHoveredElement &&
bindLinearElement(newElement, endHoveredElement, "end", app.scene);
const startElement =
newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement =
newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const startBinding =
startElement && newElement.startBinding
@ -1714,7 +1664,7 @@ export const actionChangeArrowType = register({
startBinding,
endBinding,
...updateElbowArrowPoints(newElement, elementsMap, {
points: [finalStartPoint, finalEndPoint].map(
points: [startGlobalPoint, endGlobalPoint].map(
(p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
),

View File

@ -17,8 +17,6 @@ import {
vectorDot,
vectorNormalize,
} from "@excalidraw/math";
import { isPointInShape } from "@excalidraw/utils/collision";
import { getSelectionBoxShape } from "@excalidraw/utils/shape";
import {
COLOR_PALETTE,
@ -104,9 +102,9 @@ import {
Emitter,
} from "@excalidraw/common";
import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
import {
getCommonBounds,
getElementAbsoluteCoords,
bindOrUnbindLinearElement,
bindOrUnbindLinearElements,
fixBindingsAfterDeletion,
@ -117,13 +115,8 @@ import {
shouldEnableBindingForPointerEvent,
updateBoundElements,
getSuggestedBindingsForArrows,
} from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import {
LinearElementEditor,
newElementWith,
newFrameElement,
newFreeDrawElement,
newEmbeddableElement,
@ -135,11 +128,9 @@ import {
newLinearElement,
newTextElement,
refreshTextDimensions,
} from "@excalidraw/element";
import { deepCopyElement, duplicateElements } from "@excalidraw/element";
import {
deepCopyElement,
duplicateElements,
isPointInShape,
hasBoundTextElement,
isArrowElement,
isBindingElement,
@ -160,48 +151,27 @@ import {
isFlowchartNodeElement,
isBindableElement,
isTextElement,
} from "@excalidraw/element";
import {
getLockedLinearCursorAlignSize,
getNormalizedDimensions,
isElementCompletelyInViewport,
isElementInViewport,
isInvisiblySmallElement,
} from "@excalidraw/element";
import {
getBoundTextShape,
getCornerRadius,
getElementShape,
isPathALoop,
} from "@excalidraw/element";
import {
createSrcDoc,
embeddableURLValidator,
maybeParseEmbedSrc,
getEmbedLink,
} from "@excalidraw/element";
import {
getInitializedImageElements,
loadHTMLImageElement,
normalizeSVG,
updateImageCache as _updateImageCache,
} from "@excalidraw/element";
import {
getBoundTextElement,
getContainerCenter,
getContainerElement,
isValidTextContainer,
redrawTextBoundingBox,
} from "@excalidraw/element";
import { shouldShowBoundingBox } from "@excalidraw/element";
import {
shouldShowBoundingBox,
getFrameChildren,
isCursorInFrame,
addElementsToFrame,
@ -216,29 +186,17 @@ import {
getFrameLikeTitle,
getElementsOverlappingFrame,
filterElementsEligibleAsFrameChildren,
} from "@excalidraw/element";
import {
hitElementBoundText,
hitElementBoundingBoxOnly,
hitElementItself,
} from "@excalidraw/element";
import { getVisibleSceneBounds } from "@excalidraw/element";
import {
getVisibleSceneBounds,
FlowChartCreator,
FlowChartNavigator,
getLinkDirectionFromKey,
} from "@excalidraw/element";
import { cropElement } from "@excalidraw/element";
import { wrapText } from "@excalidraw/element";
import { isElementLink, parseElementLinkFromURL } from "@excalidraw/element";
import {
cropElement,
wrapText,
isElementLink,
parseElementLinkFromURL,
isMeasureTextSupported,
normalizeText,
measureText,
@ -246,13 +204,8 @@ import {
getApproxMinLineWidth,
getApproxMinLineHeight,
getMinTextElementWidth,
} from "@excalidraw/element";
import { ShapeCache } from "@excalidraw/element";
import { getRenderOpacity } from "@excalidraw/element";
import {
ShapeCache,
getRenderOpacity,
editGroupForSelectedElement,
getElementsInGroup,
getSelectedGroupIdForElement,
@ -260,42 +213,27 @@ import {
isElementInGroup,
isSelectedViaGroup,
selectGroupsForSelectedElements,
} from "@excalidraw/element";
import { syncInvalidIndices, syncMovedIndices } from "@excalidraw/element";
import {
syncInvalidIndices,
syncMovedIndices,
excludeElementsInFramesFromSelection,
getSelectionStateForElements,
makeNextSelectedElementIds,
} from "@excalidraw/element";
import {
getResizeOffsetXY,
getResizeArrowDirection,
transformElements,
} from "@excalidraw/element";
import {
getCursorForResizingElement,
getElementWithTransformHandleType,
getTransformHandleTypeFromCoords,
} from "@excalidraw/element";
import {
dragNewElement,
dragSelectedElements,
getDragOffsetXY,
isNonDeletedElement,
Scene,
Store,
CaptureUpdateAction,
type ElementUpdate,
} from "@excalidraw/element";
import { isNonDeletedElement } from "@excalidraw/element";
import { Scene } from "@excalidraw/element";
import { Store, CaptureUpdateAction } from "@excalidraw/element";
import type { ElementUpdate } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type {
@ -5141,13 +5079,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 +5162,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 +5210,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 +5557,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 +6231,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 +9606,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

@ -133,10 +133,9 @@ describe("binding with linear elements", () => {
const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("204"));
UI.updateInput(inputX, String("199"));
expect(linear.startBinding).not.toBe(null);
});
@ -657,6 +656,7 @@ describe("stats for multiple elements", () => {
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});

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

@ -175,7 +175,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startBinding": {
"elementId": "diamond-1",
"focus": 0,
"gap": 4.545343408287929,
"gap": 4.535423522449215,
},
"strokeColor": "#e67700",
"strokeStyle": "solid",
@ -335,7 +335,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": {
"elementId": "text-2",
"focus": 0,
"gap": 14,
"gap": 16,
},
"fillStyle": "solid",
"frameId": null,
@ -1538,7 +1538,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endBinding": {
"elementId": "B",
"focus": 0,
"gap": 14,
"gap": 32,
},
"fillStyle": "solid",
"frameId": null,
@ -1789,7 +1789,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 120,
"x": 187.7545,
"x": 187.75450000000004,
"y": 44.5,
}
`;

View File

@ -781,7 +781,7 @@ describe("Test Transform", () => {
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1",
focus: -0,
gap: 14,
gap: 25,
});
expect(rect.boundElements).toStrictEqual([
{

View File

@ -1,5 +1,5 @@
import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
import { getElementLineSegments } from "@excalidraw/element";
import { getElementLineSegments, isPointInShape } from "@excalidraw/element";
import {
lineSegment,
lineSegmentIntersectionPoints,
@ -8,9 +8,7 @@ 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";
import { getBoundTextElementId } from "@excalidraw/element";
@ -208,15 +206,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

@ -5,17 +5,14 @@ import { getDiamondPoints } from "@excalidraw/element";
import { getCornerRadius } from "@excalidraw/element";
import {
bezierEquation,
curve,
curveTangent,
curveCatmullRomCubicApproxPoints,
curveCatmullRomQuadraticApproxPoints,
curveOffsetPoints,
type GlobalPoint,
offsetPointsForQuadraticBezier,
pointFrom,
pointFromVector,
pointRotateRads,
vector,
vectorNormal,
vectorNormalize,
vectorScale,
} from "@excalidraw/math";
import type {
@ -99,25 +96,14 @@ export const bootstrapCanvas = ({
function drawCatmullRomQuadraticApprox(
ctx: CanvasRenderingContext2D,
points: GlobalPoint[],
segments = 20,
tension = 0.5,
) {
ctx.lineTo(points[0][0], points[0][1]);
const pointSets = curveCatmullRomQuadraticApproxPoints(points, tension);
if (pointSets) {
for (let i = 0; i < pointSets.length - 1; i++) {
const [[cpX, cpY], [p2X, p2Y]] = pointSets[i];
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1 < 0 ? 0 : i - 1];
const p1 = points[i];
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
for (let t = 0; t <= 1; t += 1 / segments) {
const t2 = t * t;
const x =
(1 - t) * (1 - t) * p0[0] + 2 * (1 - t) * t * p1[0] + t2 * p2[0];
const y =
(1 - t) * (1 - t) * p0[1] + 2 * (1 - t) * t * p1[1] + t2 * p2[1];
ctx.lineTo(x, y);
ctx.quadraticCurveTo(cpX, cpY, p2X, p2Y);
}
}
}
@ -125,35 +111,13 @@ function drawCatmullRomQuadraticApprox(
function drawCatmullRomCubicApprox(
ctx: CanvasRenderingContext2D,
points: GlobalPoint[],
segments = 20,
tension = 0.5,
) {
ctx.lineTo(points[0][0], points[0][1]);
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1 < 0 ? 0 : i - 1];
const p1 = points[i];
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
for (let t = 0; t <= 1; t += 1 / segments) {
const t2 = t * t;
const t3 = t2 * t;
const x =
0.5 *
(2 * p1[0] +
(-p0[0] + p2[0]) * t +
(2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
(-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3);
const y =
0.5 *
(2 * p1[1] +
(-p0[1] + p2[1]) * t +
(2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
(-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3);
ctx.lineTo(x, y);
const pointSets = curveCatmullRomCubicApproxPoints(points, tension);
if (pointSets) {
for (let i = 0; i < pointSets.length; i++) {
const [[cp1x, cp1y], [cp2x, cp2y], [x, y]] = pointSets[i];
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
}
}
}
@ -184,25 +148,25 @@ export const drawHighlightForRectWithRotation = (
context.beginPath();
{
const topLeftApprox = offsetQuadraticBezier(
const topLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0, 0 + radius),
pointFrom(0, 0),
pointFrom(0 + radius, 0),
padding,
);
const topRightApprox = offsetQuadraticBezier(
const topRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width - radius, 0),
pointFrom(element.width, 0),
pointFrom(element.width, radius),
padding,
);
const bottomRightApprox = offsetQuadraticBezier(
const bottomRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width, element.height - radius),
pointFrom(element.width, element.height),
pointFrom(element.width - radius, element.height),
padding,
);
const bottomLeftApprox = offsetQuadraticBezier(
const bottomLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(radius, element.height),
pointFrom(0, element.height),
pointFrom(0, element.height - radius),
@ -227,25 +191,25 @@ export const drawHighlightForRectWithRotation = (
// mask" on a filled shape for the diamond highlight, because stroking creates
// sharp inset edges on line joins < 90 degrees.
{
const topLeftApprox = offsetQuadraticBezier(
const topLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0 + radius, 0),
pointFrom(0, 0),
pointFrom(0, 0 + radius),
-FIXED_BINDING_DISTANCE,
);
const topRightApprox = offsetQuadraticBezier(
const topRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width, radius),
pointFrom(element.width, 0),
pointFrom(element.width - radius, 0),
-FIXED_BINDING_DISTANCE,
);
const bottomRightApprox = offsetQuadraticBezier(
const bottomRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width - radius, element.height),
pointFrom(element.width, element.height),
pointFrom(element.width, element.height - radius),
-FIXED_BINDING_DISTANCE,
);
const bottomLeftApprox = offsetQuadraticBezier(
const bottomLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0, element.height - radius),
pointFrom(0, element.height),
pointFrom(radius, element.height),
@ -340,32 +304,40 @@ export const drawHighlightForDiamondWithRotation = (
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const topApprox = offsetCubicBezier(
pointFrom(topX - verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY),
pointFrom(topX, topY),
pointFrom(topX + verticalRadius, topY + horizontalRadius),
const topApprox = curveOffsetPoints(
curve(
pointFrom(topX - verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY),
pointFrom(topX, topY),
pointFrom(topX + verticalRadius, topY + horizontalRadius),
),
padding,
);
const rightApprox = offsetCubicBezier(
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
pointFrom(rightX, rightY),
pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
const rightApprox = curveOffsetPoints(
curve(
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
pointFrom(rightX, rightY),
pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
),
padding,
);
const bottomApprox = offsetCubicBezier(
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY),
pointFrom(bottomX, bottomY),
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
const bottomApprox = curveOffsetPoints(
curve(
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY),
pointFrom(bottomX, bottomY),
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
),
padding,
);
const leftApprox = offsetCubicBezier(
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
pointFrom(leftX, leftY),
pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
const leftApprox = curveOffsetPoints(
curve(
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
pointFrom(leftX, leftY),
pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
),
padding,
);
@ -373,13 +345,13 @@ export const drawHighlightForDiamondWithRotation = (
topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1],
);
context.lineTo(rightApprox[0][0], rightApprox[0][1]);
context.lineTo(rightApprox[1][0], rightApprox[1][1]);
drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(leftApprox[0][0], leftApprox[0][1]);
context.lineTo(leftApprox[1][0], leftApprox[1][1]);
drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]);
context.lineTo(topApprox[1][0], topApprox[1][1]);
drawCatmullRomCubicApprox(context, topApprox);
}
@ -395,32 +367,40 @@ export const drawHighlightForDiamondWithRotation = (
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const topApprox = offsetCubicBezier(
pointFrom(topX + verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY),
pointFrom(topX, topY),
pointFrom(topX - verticalRadius, topY + horizontalRadius),
const topApprox = curveOffsetPoints(
curve(
pointFrom(topX + verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY),
pointFrom(topX, topY),
pointFrom(topX - verticalRadius, topY + horizontalRadius),
),
-FIXED_BINDING_DISTANCE,
);
const rightApprox = offsetCubicBezier(
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
pointFrom(rightX, rightY),
pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
const rightApprox = curveOffsetPoints(
curve(
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
pointFrom(rightX, rightY),
pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
),
-FIXED_BINDING_DISTANCE,
);
const bottomApprox = offsetCubicBezier(
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY),
pointFrom(bottomX, bottomY),
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
const bottomApprox = curveOffsetPoints(
curve(
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY),
pointFrom(bottomX, bottomY),
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
),
-FIXED_BINDING_DISTANCE,
);
const leftApprox = offsetCubicBezier(
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
pointFrom(leftX, leftY),
pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
const leftApprox = curveOffsetPoints(
curve(
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
pointFrom(leftX, leftY),
pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
),
-FIXED_BINDING_DISTANCE,
);
@ -428,66 +408,16 @@ export const drawHighlightForDiamondWithRotation = (
topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1],
);
context.lineTo(leftApprox[0][0], leftApprox[0][1]);
context.lineTo(leftApprox[1][0], leftApprox[1][1]);
drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(rightApprox[0][0], rightApprox[0][1]);
context.lineTo(rightApprox[1][0], rightApprox[1][1]);
drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]);
context.lineTo(topApprox[1][0], topApprox[1][1]);
drawCatmullRomCubicApprox(context, topApprox);
}
context.closePath();
context.fill();
context.restore();
};
function offsetCubicBezier(
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
p3: GlobalPoint,
offsetDist: number,
steps = 20,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const c = curve(p0, p1, p2, p3);
const point = bezierEquation(c, t);
const tangent = vectorNormalize(curveTangent(c, t));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
}
return offsetPoints;
}
function offsetQuadraticBezier(
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
offsetDist: number,
steps = 20,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const t1 = 1 - t;
const point = pointFrom<GlobalPoint>(
t1 * t1 * p0[0] + 2 * t1 * t * p1[0] + t * t * p2[0],
t1 * t1 * p0[1] + 2 * t1 * t * p1[1] + t * t * p2[1],
);
const tangentX = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]);
const tangentY = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]);
const tangent = vectorNormalize(vector(tangentX, tangentY));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
}
return offsetPoints;
}

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

@ -1221,7 +1221,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"version": 3,
"versionNonce": 1150084233,
"width": 20,
"x": -10,
"x": -7,
"y": 0,
}
`;
@ -1274,7 +1274,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"strokeWidth": 2,
"type": "rectangle",
"width": 20,
"x": -10,
"x": -7,
"y": 0,
},
"inserted": {
@ -2097,7 +2097,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"version": 3,
"versionNonce": 1150084233,
"width": 20,
"x": -10,
"x": -7,
"y": 0,
}
`;
@ -2150,7 +2150,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"strokeWidth": 2,
"type": "rectangle",
"width": 20,
"x": -10,
"x": -7,
"y": 0,
},
"inserted": {
@ -2307,7 +2307,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"version": 4,
"versionNonce": 1014066025,
"width": 20,
"x": -10,
"x": -7,
"y": 0,
}
`;
@ -2360,7 +2360,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"strokeWidth": 2,
"type": "rectangle",
"width": 20,
"x": -10,
"x": -7,
"y": 0,
},
"inserted": {
@ -2548,7 +2548,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"version": 3,
"versionNonce": 1150084233,
"width": 20,
"x": -10,
"x": -7,
"y": 0,
}
`;
@ -2582,7 +2582,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"version": 5,
"versionNonce": 400692809,
"width": 20,
"x": 0,
"x": 3,
"y": 10,
}
`;
@ -2635,7 +2635,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"strokeWidth": 2,
"type": "rectangle",
"width": 20,
"x": -10,
"x": -7,
"y": 0,
},
"inserted": {
@ -2689,7 +2689,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"strokeWidth": 2,
"type": "rectangle",
"width": 20,
"x": 0,
"x": 3,
"y": 10,
},
"inserted": {
@ -8639,8 +8639,8 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
},
],
"left": -17,
"top": -7,
"left": 10,
"top": 20,
},
"croppingElementId": null,
"currentChartType": "bar",

View File

@ -198,7 +198,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "102.45605",
"height": "102.44561",
"id": "id691",
"index": "a2",
"isDeleted": false,
@ -212,8 +212,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"102.80179",
"102.45605",
"102.69685",
"102.44561",
],
],
"roughness": 1,
@ -228,8 +228,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 37,
"width": "102.80179",
"x": "-0.42182",
"width": "102.69685",
"x": "-0.30656",
"y": 0,
}
`;
@ -312,15 +312,15 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"focus": 0,
"gap": 1,
},
"height": "70.45017",
"height": "70.31130",
"points": [
[
0,
0,
],
[
"100.70774",
"70.45017",
"100.51087",
"70.31130",
],
],
"startBinding": {
@ -335,15 +335,15 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"focus": "-0.02000",
"gap": 1,
},
"height": "0.09250",
"height": "0.10543",
"points": [
[
0,
0,
],
[
"98.58579",
"0.09250",
"98.00000",
"0.10543",
],
],
"startBinding": {
@ -396,30 +396,30 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"id691": {
"deleted": {
"height": "102.45584",
"height": "102.44538",
"points": [
[
0,
0,
],
[
"102.79971",
"102.45584",
"102.69463",
"102.44538",
],
],
"startBinding": null,
"y": 0,
},
"inserted": {
"height": "70.33521",
"height": "70.20818",
"points": [
[
0,
0,
],
[
"100.78887",
"70.33521",
"100.62538",
"70.20818",
],
],
"startBinding": {
@ -427,7 +427,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"focus": "0.02970",
"gap": 1,
},
"y": "35.20327",
"y": "35.26761",
},
},
},
@ -810,7 +810,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"updated": 1,
"version": 33,
"width": 100,
"x": "149.29289",
"x": 149,
"y": 0,
}
`;
@ -1229,7 +1229,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "1.30038",
"height": "1.33342",
"id": "id715",
"index": "Zz",
"isDeleted": false,
@ -1243,8 +1243,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"98.58579",
"1.30038",
98,
"1.33342",
],
],
"roughness": 1,
@ -1267,8 +1267,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 11,
"width": "98.58579",
"x": "0.70711",
"width": 98,
"x": 1,
"y": 0,
}
`;
@ -1595,7 +1595,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "1.30038",
"height": "1.33342",
"id": "id725",
"index": "a0",
"isDeleted": false,
@ -1609,8 +1609,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"98.58579",
"1.30038",
98,
"1.33342",
],
],
"roughness": 1,
@ -1633,8 +1633,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 11,
"width": "98.58579",
"x": "0.70711",
"width": 98,
"x": 1,
"y": 0,
}
`;
@ -1751,7 +1751,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "11.27227",
"height": "11.38145",
"index": "a0",
"isDeleted": false,
"lastCommittedPoint": null,
@ -1764,8 +1764,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"98.58579",
"11.27227",
"98.00000",
"11.38145",
],
],
"roughness": 1,
@ -1786,8 +1786,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": "98.58579",
"x": "0.70711",
"width": "98.00000",
"x": 1,
"y": 0,
},
"inserted": {
@ -2296,7 +2296,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "374.05754",
"height": "373.94428",
"id": "id740",
"index": "a2",
"isDeleted": false,
@ -2310,8 +2310,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"502.78936",
"-374.05754",
"502.66843",
"-373.94428",
],
],
"roughness": 1,
@ -2330,9 +2330,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 10,
"width": "502.78936",
"x": "-0.83465",
"y": "-36.58211",
"width": "502.66843",
"x": "-0.74818",
"y": "-36.64616",
}
`;
@ -14933,7 +14933,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0,
],
[
"98.58579",
98,
0,
],
],
@ -14953,8 +14953,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow",
"updated": 1,
"version": 10,
"width": "98.58579",
"x": "0.70711",
"width": 98,
"x": 1,
"y": 0,
}
`;
@ -15632,7 +15632,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0,
],
[
"98.58579",
98,
0,
],
],
@ -15652,8 +15652,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow",
"updated": 1,
"version": 10,
"width": "98.58579",
"x": "0.70711",
"width": 98,
"x": 1,
"y": 0,
}
`;
@ -16250,7 +16250,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0,
],
[
"98.58579",
98,
0,
],
],
@ -16270,8 +16270,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow",
"updated": 1,
"version": 10,
"width": "98.58579",
"x": "0.70711",
"width": 98,
"x": 1,
"y": 0,
}
`;
@ -16866,7 +16866,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0,
],
[
"98.58579",
98,
0,
],
],
@ -16886,8 +16886,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow",
"updated": 1,
"version": 10,
"width": "98.58579",
"x": "0.70711",
"width": 98,
"x": 1,
"y": 0,
}
`;
@ -17582,7 +17582,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0,
],
[
"98.58579",
98,
0,
],
],
@ -17602,8 +17602,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow",
"updated": 1,
"version": 11,
"width": "98.58579",
"x": "0.70711",
"width": 98,
"x": 1,
"y": 0,
}
`;

View File

@ -196,7 +196,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "87.29887",
"height": "81.40630",
"id": "id6",
"index": "a2",
"isDeleted": false,
@ -210,8 +210,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
0,
],
[
"86.85786",
"87.29887",
"81.00000",
"81.40630",
],
],
"roughness": 1,
@ -232,8 +232,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
"updated": 1,
"version": 11,
"versionNonce": 1051383431,
"width": "86.85786",
"x": "107.07107",
"y": "47.07107",
"width": "81.00000",
"x": "110.00000",
"y": 50,
}
`;

View File

@ -2465,7 +2465,7 @@ exports[`regression tests > can drag element that covers another element, while
"scrolledOutside": false,
"searchMatches": null,
"selectedElementIds": {
"id3": true,
"id0": true,
},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -2667,7 +2667,7 @@ exports[`regression tests > can drag element that covers another element, while
"delta": Delta {
"deleted": {
"selectedElementIds": {
"id3": true,
"id0": true,
},
},
"inserted": {
@ -2681,7 +2681,7 @@ exports[`regression tests > can drag element that covers another element, while
"added": {},
"removed": {},
"updated": {
"id3": {
"id0": {
"deleted": {
"x": 300,
"y": 300,

View File

@ -115,9 +115,10 @@ describe("contextMenu element", () => {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 3,
clientY: 3,
clientX: 30,
clientY: 30,
});
const contextMenu = UI.queryContextMenu();
const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li");
@ -304,12 +305,12 @@ describe("contextMenu element", () => {
it("selecting 'Copy styles' in context menu copies styles", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.down(13, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 3,
clientX: 13,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();
@ -389,12 +390,12 @@ describe("contextMenu element", () => {
it("selecting 'Delete' in context menu deletes element", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.down(13, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 3,
clientX: 13,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();
@ -405,12 +406,12 @@ describe("contextMenu element", () => {
it("selecting 'Add to library' in context menu adds element to library", async () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.down(13, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 3,
clientX: 13,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();
@ -424,12 +425,12 @@ describe("contextMenu element", () => {
it("selecting 'Duplicate' in context menu duplicates element", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.down(13, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 3,
clientX: 13,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();

View File

@ -383,25 +383,22 @@ const proxy = <T extends ExcalidrawElement>(
the proxy */
get(): typeof element;
} => {
return new Proxy(
{},
{
get(target, prop) {
const currentElement = h.elements.find(
({ id }) => id === element.id,
) as any;
if (prop === "get") {
if (currentElement.hasOwnProperty("get")) {
throw new Error(
"trying to get `get` test property, but ExcalidrawElement seems to define its own",
);
}
return () => currentElement;
return new Proxy(element, {
get(target, prop) {
const currentElement = h.elements.find(
({ id }) => id === element.id,
) as any;
if (prop === "get") {
if (currentElement.hasOwnProperty("get")) {
throw new Error(
"trying to get `get` test property, but ExcalidrawElement seems to define its own",
);
}
return currentElement[prop];
},
return () => currentElement;
}
return currentElement[prop];
},
) as any;
}) as any;
};
/** Tools that can be used to draw shapes */

View File

@ -124,8 +124,8 @@ describe("move element", () => {
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([201, 2]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[107.07, 47.07]]);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[86.86, 87.3]]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[110, 50]]);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[81, 81.4]]);
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});

View File

@ -704,7 +704,7 @@ describe("regression tests", () => {
// pointer down on rectangle
mouse.reset();
mouse.down(100, 100);
mouse.down(110, 100); // Rectangle is rounded, there is no selection at the corner
mouse.up(200, 200);
expect(API.getSelectedElement().type).toBe("rectangle");
@ -989,6 +989,7 @@ describe("regression tests", () => {
// select rectangle
mouse.reset();
mouse.moveTo(30, 0); // Rectangle is rounded, there is no selection at the corner
mouse.click();
// click on intersection between ellipse and rectangle
@ -1155,6 +1156,7 @@ it(
// Select first rectangle while keeping third one selected.
// Third rectangle is selected because it was the last element to be created.
mouse.reset();
mouse.moveTo(30, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
@ -1176,6 +1178,7 @@ it(
// Pointer down o first rectangle that is part of the group
mouse.reset();
mouse.moveTo(30, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.down();
});

View File

@ -35,7 +35,7 @@ test("unselected bound arrow updates when rotating its target element", async ()
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
expect(arrow.x).toBeCloseTo(-80);
expect(arrow.y).toBeCloseTo(50);
expect(arrow.width).toBeCloseTo(116.7, 1);
expect(arrow.width).toBeCloseTo(110.7, 1);
expect(arrow.height).toBeCloseTo(0);
});

View File

@ -682,7 +682,7 @@ describe("textWysiwyg", () => {
expect(diamond.height).toBe(70);
});
it("should bind text to container when double clicked on center of transparent container", async () => {
it("should bind text to container when double clicked inside of the transparent container", async () => {
const rectangle = API.createElement({
type: "rectangle",
x: 10,
@ -693,7 +693,7 @@ describe("textWysiwyg", () => {
});
API.setElements([rectangle]);
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
mouse.doubleClickAt(rectangle.x + 20, rectangle.y + 20);
expect(h.elements.length).toBe(2);
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");

View File

@ -1,4 +1,8 @@
# @excalidraw/math
# @excalidraw/math - 2D Vector Graphics Math Library
The package contains a collection of (mostly) independent functions providing the mathematical basis for Excalidraw's rendering, hit detection, bounds checking and anything using math underneath.
The philosophy of the library is to be self-contained and therefore there is no dependency on any other package. It only contains pure functions. It also prefers analytical solutions vs numberical wherever possible. Since this library is used in a high performance context, we might chose to use a numerical approximation, even if an analytical solution is available to preserve performance.
## Install

View File

@ -1,3 +0,0 @@
/// <reference types="vite/client" />
import "@excalidraw/excalidraw/global";
import "@excalidraw/excalidraw/css";

View File

@ -1,6 +1,6 @@
{
"name": "@excalidraw/math",
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"types": "./dist/types/math/src/index.d.ts",
"main": "./dist/prod/index.js",
@ -19,7 +19,7 @@
"files": [
"dist/*"
],
"description": "Excalidraw math functions",
"description": "Excalidraw math library for 2D vector graphics.",
"publishConfig": {
"access": "public"
},

View File

@ -1,4 +1,4 @@
import { PRECISION } from "./utils";
import { PRECISION } from "./constants";
import type {
Degrees,

View File

@ -0,0 +1,57 @@
export const PRECISION = 10e-5;
// Legendre-Gauss abscissae (x values) and weights for n=24
// Refeerence: https://pomax.github.io/bezierinfo/legendre-gauss.html
export const LegendreGaussN24TValues = [
-0.0640568928626056260850430826247450385909,
0.0640568928626056260850430826247450385909,
-0.1911188674736163091586398207570696318404,
0.1911188674736163091586398207570696318404,
-0.3150426796961633743867932913198102407864,
0.3150426796961633743867932913198102407864,
-0.4337935076260451384870842319133497124524,
0.4337935076260451384870842319133497124524,
-0.5454214713888395356583756172183723700107,
0.5454214713888395356583756172183723700107,
-0.6480936519369755692524957869107476266696,
0.6480936519369755692524957869107476266696,
-0.7401241915785543642438281030999784255232,
0.7401241915785543642438281030999784255232,
-0.8200019859739029219539498726697452080761,
0.8200019859739029219539498726697452080761,
-0.8864155270044010342131543419821967550873,
0.8864155270044010342131543419821967550873,
-0.9382745520027327585236490017087214496548,
0.9382745520027327585236490017087214496548,
-0.9747285559713094981983919930081690617411,
0.9747285559713094981983919930081690617411,
-0.9951872199970213601799974097007368118745,
0.9951872199970213601799974097007368118745,
];
export const LegendreGaussN24CValues = [
0.1279381953467521569740561652246953718517,
0.1279381953467521569740561652246953718517,
0.1258374563468282961213753825111836887264,
0.1258374563468282961213753825111836887264,
0.121670472927803391204463153476262425607,
0.121670472927803391204463153476262425607,
0.1155056680537256013533444839067835598622,
0.1155056680537256013533444839067835598622,
0.1074442701159656347825773424466062227946,
0.1074442701159656347825773424466062227946,
0.0976186521041138882698806644642471544279,
0.0976186521041138882698806644642471544279,
0.086190161531953275917185202983742667185,
0.086190161531953275917185202983742667185,
0.0733464814110803057340336152531165181193,
0.0733464814110803057340336152531165181193,
0.0592985849154367807463677585001085845412,
0.0592985849154367807463677585001085845412,
0.0442774388174198061686027482113382288593,
0.0442774388174198061686027482113382288593,
0.0285313886289336631813078159518782864491,
0.0285313886289336631813078159518782864491,
0.0123412297999871995468056670700372915759,
0.0123412297999871995468056670700372915759,
];

View File

@ -1,8 +1,7 @@
import type { Bounds } from "@excalidraw/element";
import { isPoint, pointDistance, pointFrom } from "./point";
import { isPoint, pointDistance, pointFrom, pointFromVector } from "./point";
import { rectangle, rectangleIntersectLineSegment } from "./rectangle";
import { vector } from "./vector";
import { vector, vectorNormal, vectorNormalize, vectorScale } from "./vector";
import { LegendreGaussN24CValues, LegendreGaussN24TValues } from "./constants";
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
@ -23,81 +22,6 @@ export function curve<Point extends GlobalPoint | LocalPoint>(
return [a, b, c, d] as Curve<Point>;
}
function gradient(
f: (t: number, s: number) => number,
t0: number,
s0: number,
delta: number = 1e-6,
): number[] {
return [
(f(t0 + delta, s0) - f(t0 - delta, s0)) / (2 * delta),
(f(t0, s0 + delta) - f(t0, s0 - delta)) / (2 * delta),
];
}
function solve(
f: (t: number, s: number) => [number, number],
t0: number,
s0: number,
tolerance: number = 1e-3,
iterLimit: number = 10,
): number[] | null {
let error = Infinity;
let iter = 0;
while (error >= tolerance) {
if (iter >= iterLimit) {
return null;
}
const y0 = f(t0, s0);
const jacobian = [
gradient((t, s) => f(t, s)[0], t0, s0),
gradient((t, s) => f(t, s)[1], t0, s0),
];
const b = [[-y0[0]], [-y0[1]]];
const det =
jacobian[0][0] * jacobian[1][1] - jacobian[0][1] * jacobian[1][0];
if (det === 0) {
return null;
}
const iJ = [
[jacobian[1][1] / det, -jacobian[0][1] / det],
[-jacobian[1][0] / det, jacobian[0][0] / det],
];
const h = [
[iJ[0][0] * b[0][0] + iJ[0][1] * b[1][0]],
[iJ[1][0] * b[0][0] + iJ[1][1] * b[1][0]],
];
t0 = t0 + h[0][0];
s0 = s0 + h[1][0];
const [tErr, sErr] = f(t0, s0);
error = Math.max(Math.abs(tErr), Math.abs(sErr));
iter += 1;
}
return [t0, s0];
}
export const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>,
t: number,
) =>
pointFrom<Point>(
(1 - t) ** 3 * c[0][0] +
3 * (1 - t) ** 2 * t * c[1][0] +
3 * (1 - t) * t ** 2 * c[2][0] +
t ** 3 * c[3][0],
(1 - t) ** 3 * c[0][1] +
3 * (1 - t) ** 2 * t * c[1][1] +
3 * (1 - t) * t ** 2 * c[2][1] +
t ** 3 * c[3][1],
);
/**
* Computes the intersection between a cubic spline and a line segment.
*/
@ -105,12 +29,19 @@ export function curveIntersectLineSegment<
Point extends GlobalPoint | LocalPoint,
>(c: Curve<Point>, l: LineSegment<Point>): Point[] {
// Optimize by doing a cheap bounding box check first
const bounds = curveBounds(c);
const [p0, p1, p2, p3] = c;
if (
rectangleIntersectLineSegment(
rectangle(
pointFrom(bounds[0], bounds[1]),
pointFrom(bounds[2], bounds[3]),
pointFrom(
Math.min(p0[0], p1[0], p2[0], p3[0]),
Math.min(p0[1], p1[1], p2[1], p3[1]),
),
pointFrom(
Math.max(p0[0], p1[0], p2[0], p3[0]),
Math.max(p0[1], p1[1], p2[1], p3[1]),
),
),
l,
).length === 0
@ -295,11 +226,303 @@ export function curveTangent<Point extends GlobalPoint | LocalPoint>(
);
}
function curveBounds<Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>,
): Bounds {
const [P0, P1, P2, P3] = c;
const x = [P0[0], P1[0], P2[0], P3[0]];
const y = [P0[1], P1[1], P2[1], P3[1]];
return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)];
export function curveCatmullRomQuadraticApproxPoints(
points: GlobalPoint[],
tension = 0.5,
) {
if (points.length < 2) {
return;
}
const pointSets: [GlobalPoint, GlobalPoint][] = [];
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1 < 0 ? 0 : i - 1];
const p1 = points[i];
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
const cpX = p1[0] + ((p2[0] - p0[0]) * tension) / 2;
const cpY = p1[1] + ((p2[1] - p0[1]) * tension) / 2;
pointSets.push([
pointFrom<GlobalPoint>(cpX, cpY),
pointFrom<GlobalPoint>(p2[0], p2[1]),
]);
}
return pointSets;
}
export function curveCatmullRomCubicApproxPoints<
Point extends GlobalPoint | LocalPoint,
>(points: Point[], tension = 0.5) {
if (points.length < 2) {
return;
}
const pointSets: Curve<Point>[] = [];
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1 < 0 ? 0 : i - 1];
const p1 = points[i];
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
const tangent1 = [(p2[0] - p0[0]) * tension, (p2[1] - p0[1]) * tension];
const tangent2 = [(p3[0] - p1[0]) * tension, (p3[1] - p1[1]) * tension];
const cp1x = p1[0] + tangent1[0] / 3;
const cp1y = p1[1] + tangent1[1] / 3;
const cp2x = p2[0] - tangent2[0] / 3;
const cp2y = p2[1] - tangent2[1] / 3;
pointSets.push(
curve(
pointFrom(p1[0], p1[1]),
pointFrom(cp1x, cp1y),
pointFrom(cp2x, cp2y),
pointFrom(p2[0], p2[1]),
),
);
}
return pointSets;
}
export function curveOffsetPoints(
[p0, p1, p2, p3]: Curve<GlobalPoint>,
offset: number,
steps = 50,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const c = curve(p0, p1, p2, p3);
const point = bezierEquation(c, t);
const tangent = vectorNormalize(curveTangent(c, t));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offset), point));
}
return offsetPoints;
}
export function offsetPointsForQuadraticBezier(
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
offsetDist: number,
steps = 50,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const t1 = 1 - t;
const point = pointFrom<GlobalPoint>(
t1 * t1 * p0[0] + 2 * t1 * t * p1[0] + t * t * p2[0],
t1 * t1 * p0[1] + 2 * t1 * t * p1[1] + t * t * p2[1],
);
const tangentX = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]);
const tangentY = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]);
const tangent = vectorNormalize(vector(tangentX, tangentY));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
}
return offsetPoints;
}
/**
* Implementation based on Legendre-Gauss quadrature for more accurate arc
* length calculation.
*
* Reference: https://pomax.github.io/bezierinfo/#arclength
*
* @param c The curve to calculate the length of
* @returns The approximated length of the curve
*/
export function curveLength<P extends GlobalPoint | LocalPoint>(
c: Curve<P>,
): number {
const z2 = 0.5;
let sum = 0;
for (let i = 0; i < 24; i++) {
const t = z2 * LegendreGaussN24TValues[i] + z2;
const derivativeVector = curveTangent(c, t);
const magnitude = Math.sqrt(
derivativeVector[0] * derivativeVector[0] +
derivativeVector[1] * derivativeVector[1],
);
sum += LegendreGaussN24CValues[i] * magnitude;
}
return z2 * sum;
}
/**
* Calculates the curve length from t=0 to t=parameter using the same
* Legendre-Gauss quadrature method used in curveLength
*
* @param c The curve to calculate the partial length for
* @param t The parameter value (0 to 1) to calculate length up to
* @returns The length of the curve from beginning to parameter t
*/
export function curveLengthAtParameter<P extends GlobalPoint | LocalPoint>(
c: Curve<P>,
t: number,
): number {
if (t <= 0) {
return 0;
}
if (t >= 1) {
return curveLength(c);
}
// Scale and shift the integration interval from [0,t] to [-1,1]
// which is what the Legendre-Gauss quadrature expects
const z1 = t / 2;
const z2 = t / 2;
let sum = 0;
for (let i = 0; i < 24; i++) {
const parameter = z1 * LegendreGaussN24TValues[i] + z2;
const derivativeVector = curveTangent(c, parameter);
const magnitude = Math.sqrt(
derivativeVector[0] * derivativeVector[0] +
derivativeVector[1] * derivativeVector[1],
);
sum += LegendreGaussN24CValues[i] * magnitude;
}
return z1 * sum; // Scale the result back to the original interval
}
/**
* Calculates the point at a specific percentage of a curve's total length
* using binary search for improved efficiency and accuracy.
*
* @param c The curve to calculate point on
* @param percent A value between 0 and 1 representing the percentage of the curve's length
* @returns The point at the specified percentage of curve length
*/
export function curvePointAtLength<P extends GlobalPoint | LocalPoint>(
c: Curve<P>,
percent: number,
): P {
if (percent <= 0) {
return bezierEquation(c, 0);
}
if (percent >= 1) {
return bezierEquation(c, 1);
}
const totalLength = curveLength(c);
const targetLength = totalLength * percent;
// Binary search to find parameter t where length at t equals target length
let tMin = 0;
let tMax = 1;
let t = percent; // Start with a reasonable guess (t = percent)
let currentLength = 0;
// Tolerance for length comparison and iteration limit to avoid infinite loops
const tolerance = totalLength * 0.0001;
const maxIterations = 20;
for (let iteration = 0; iteration < maxIterations; iteration++) {
currentLength = curveLengthAtParameter(c, t);
const error = Math.abs(currentLength - targetLength);
if (error < tolerance) {
break;
}
if (currentLength < targetLength) {
tMin = t;
} else {
tMax = t;
}
t = (tMin + tMax) / 2;
}
return bezierEquation(c, t);
}
function bezierEquation<Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>,
t: number,
) {
return pointFrom<Point>(
(1 - t) ** 3 * c[0][0] +
3 * (1 - t) ** 2 * t * c[1][0] +
3 * (1 - t) * t ** 2 * c[2][0] +
t ** 3 * c[3][0],
(1 - t) ** 3 * c[0][1] +
3 * (1 - t) ** 2 * t * c[1][1] +
3 * (1 - t) * t ** 2 * c[2][1] +
t ** 3 * c[3][1],
);
}
function gradient(
f: (t: number, s: number) => number,
t0: number,
s0: number,
delta: number = 1e-6,
): number[] {
return [
(f(t0 + delta, s0) - f(t0 - delta, s0)) / (2 * delta),
(f(t0, s0 + delta) - f(t0, s0 - delta)) / (2 * delta),
];
}
function solve(
f: (t: number, s: number) => [number, number],
t0: number,
s0: number,
tolerance: number = 1e-3,
iterLimit: number = 10,
): number[] | null {
let error = Infinity;
let iter = 0;
while (error >= tolerance) {
if (iter >= iterLimit) {
return null;
}
const y0 = f(t0, s0);
const jacobian = [
gradient((t, s) => f(t, s)[0], t0, s0),
gradient((t, s) => f(t, s)[1], t0, s0),
];
const b = [[-y0[0]], [-y0[1]]];
const det =
jacobian[0][0] * jacobian[1][1] - jacobian[0][1] * jacobian[1][0];
if (det === 0) {
return null;
}
const iJ = [
[jacobian[1][1] / det, -jacobian[0][1] / det],
[-jacobian[1][0] / det, jacobian[0][0] / det],
];
const h = [
[iJ[0][0] * b[0][0] + iJ[0][1] * b[1][0]],
[iJ[1][0] * b[0][0] + iJ[1][1] * b[1][0]],
];
t0 = t0 + h[0][0];
s0 = s0 + h[1][0];
const [tErr, sErr] = f(t0, s0);
error = Math.max(Math.abs(tErr), Math.abs(sErr));
iter += 1;
}
return [t0, s0];
}

View File

@ -4,7 +4,7 @@ import {
pointFromVector,
pointsEqual,
} from "./point";
import { PRECISION } from "./utils";
import { PRECISION } from "./constants";
import {
vector,
vectorAdd,

View File

@ -1,4 +1,5 @@
export * from "./angle";
export * from "./constants";
export * from "./curve";
export * from "./line";
export * from "./point";

View File

@ -1,5 +1,5 @@
import { degreesToRadians } from "./angle";
import { PRECISION } from "./utils";
import { PRECISION } from "./constants";
import { vectorFromPoint, vectorScale } from "./vector";
import type {

View File

@ -1,6 +1,6 @@
import { pointsEqual } from "./point";
import { lineSegment, pointOnLineSegment } from "./segment";
import { PRECISION } from "./utils";
import { PRECISION } from "./constants";
import type { GlobalPoint, LocalPoint, Polygon } from "./types";

View File

@ -5,7 +5,7 @@ import {
pointFromVector,
pointRotateRads,
} from "./point";
import { PRECISION } from "./utils";
import { PRECISION } from "./constants";
import {
vectorAdd,
vectorCross,

View File

@ -1,4 +1,4 @@
export const PRECISION = 10e-5;
import { PRECISION } from "./constants";
export const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max);

View File

@ -21,13 +21,23 @@ export function vector(
*
* @param p The point to turn into a vector
* @param origin The origin point in a given coordiante system
* @returns The created vector from the point and the origin
* @param threshold The threshold to consider the vector as 'undefined'
* @param defaultValue The default value to return if the vector is 'undefined'
* @returns The created vector from the point and the origin or default
*/
export function vectorFromPoint<Point extends GlobalPoint | LocalPoint>(
p: Point,
origin: Point = [0, 0] as Point,
threshold?: number,
defaultValue: Vector = [0, 1] as Vector,
): Vector {
return vector(p[0] - origin[0], p[1] - origin[1]);
const vec = vector(p[0] - origin[0], p[1] - origin[1]);
if (threshold && vectorMagnitudeSq(vec) < threshold * threshold) {
return defaultValue;
}
return vec;
}
/**

View File

@ -4,6 +4,9 @@ import {
curve,
curveClosestPoint,
curveIntersectLineSegment,
curveLength,
curveLengthAtParameter,
curvePointAtLength,
curvePointDistance,
} from "../src/curve";
import { pointFrom } from "../src/point";
@ -99,4 +102,45 @@ describe("Math curve", () => {
expect(curvePointDistance(c, p)).toBeCloseTo(6.695873043213627);
});
});
describe("length", () => {
it("can be determined", () => {
const c = curve(
pointFrom(-50, -50),
pointFrom(10, -50),
pointFrom(10, 50),
pointFrom(50, 50),
);
expect(curveLength(c)).toBeCloseTo(150.0, 0);
});
});
describe("point at given parameter", () => {
it("can be determined", () => {
const c = curve(
pointFrom(-50, -50),
pointFrom(10, -50),
pointFrom(10, 50),
pointFrom(50, 50),
);
expect(curveLengthAtParameter(c, 0.5)).toBeCloseTo(80.83);
});
});
describe("point at given length", () => {
it("can be determined", () => {
const c = curve(
pointFrom(-50, -50),
pointFrom(10, -50),
pointFrom(10, 50),
pointFrom(50, 50),
);
expect(curvePointAtLength(c, 0.5)).toEqual([
4.802938740176614, -5.301185927237384,
]);
});
});
});

View File

@ -3,6 +3,6 @@
"compilerOptions": {
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
"include": ["src/**/*"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
}

View File

@ -1,135 +0,0 @@
import {
lineSegment,
pointFrom,
polygonIncludesPoint,
pointOnLineSegment,
pointOnPolygon,
polygonFromPoints,
type GlobalPoint,
type LocalPoint,
type Polygon,
} from "@excalidraw/math";
import type { Curve } from "@excalidraw/math";
import { pointInEllipse, pointOnEllipse } from "./shape";
import type { Polycurve, Polyline, GeometricShape } 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,
) => {
// 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`);
}
};
// check if the given point is considered inside the element's border
export const isPointInShape = <Point extends GlobalPoint | LocalPoint>(
point: Point,
shape: GeometricShape<Point>,
) => {
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`);
}
};
// check if the given element is in the given bounds
export const isPointInBounds = <Point extends GlobalPoint | LocalPoint>(
point: Point,
bounds: Polygon<Point>,
) => {
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>,
) => {
const [p0, p1, p2, p3] = curve;
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
return (t: number, idx: number) =>
Math.pow(1 - t, 3) * p3[idx] +
3 * t * Math.pow(1 - t, 2) * p2[idx] +
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
p0[idx] * Math.pow(t, 3);
};
const polyLineFromCurve = <Point extends LocalPoint | GlobalPoint>(
curve: Curve<Point>,
segments = 10,
): Polyline<Point> => {
const equation = cubicBezierEquation(curve);
let startingPoint = [equation(0, 0), equation(0, 1)] as Point;
const lineSegments: Polyline<Point> = [];
let t = 0;
const increment = 1 / segments;
for (let i = 0; i < segments; i++) {
t += increment;
if (t <= 1) {
const nextPoint: Point = pointFrom(equation(t, 0), equation(t, 1));
lineSegments.push(lineSegment(startingPoint, nextPoint));
startingPoint = nextPoint;
}
}
return lineSegments;
};
export const pointOnCurve = <Point extends LocalPoint | GlobalPoint>(
point: Point,
curve: Curve<Point>,
threshold: number,
) => {
return pointOnPolyline(point, polyLineFromCurve(curve), threshold);
};
export const pointOnPolyline = <Point extends LocalPoint | GlobalPoint>(
point: Point,
polyline: Polyline<Point>,
threshold = 10e-5,
) => {
return polyline.some((line) => pointOnLineSegment(point, line, threshold));
};

View File

@ -1,90 +0,0 @@
import {
curve,
degreesToRadians,
lineSegment,
lineSegmentRotate,
pointFrom,
pointRotateDegs,
} from "@excalidraw/math";
import type { Curve, Degrees, GlobalPoint } from "@excalidraw/math";
import { pointOnCurve, pointOnPolyline } from "../src/collision";
import type { Polyline } from "../src/shape";
describe("point and curve", () => {
const c: Curve<GlobalPoint> = curve(
pointFrom(1.4, 1.65),
pointFrom(1.9, 7.9),
pointFrom(5.9, 1.65),
pointFrom(6.44, 4.84),
);
it("point on curve", () => {
expect(pointOnCurve(c[0], c, 10e-5)).toBe(true);
expect(pointOnCurve(c[3], c, 10e-5)).toBe(true);
expect(pointOnCurve(pointFrom(2, 4), c, 0.1)).toBe(true);
expect(pointOnCurve(pointFrom(4, 4.4), c, 0.1)).toBe(true);
expect(pointOnCurve(pointFrom(5.6, 3.85), c, 0.1)).toBe(true);
expect(pointOnCurve(pointFrom(5.6, 4), c, 0.1)).toBe(false);
expect(pointOnCurve(c[1], c, 0.1)).toBe(false);
expect(pointOnCurve(c[2], c, 0.1)).toBe(false);
});
});
describe("point and polylines", () => {
const polyline: Polyline<GlobalPoint> = [
lineSegment(pointFrom(1, 0), pointFrom(1, 2)),
lineSegment(pointFrom(1, 2), pointFrom(2, 2)),
lineSegment(pointFrom(2, 2), pointFrom(2, 1)),
lineSegment(pointFrom(2, 1), pointFrom(3, 1)),
];
it("point on the line", () => {
expect(pointOnPolyline(pointFrom(1, 0), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(1, 2), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(2, 2), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(2, 1), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(3, 1), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(1, 1), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(2, 1.5), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(2.5, 1), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(0, 1), polyline)).toBe(false);
expect(pointOnPolyline(pointFrom(2.1, 1.5), polyline)).toBe(false);
});
it("point on the line with rotation", () => {
const truePoints = [
pointFrom(1, 0),
pointFrom(1, 2),
pointFrom(2, 2),
pointFrom(2, 1),
pointFrom(3, 1),
];
truePoints.forEach((p) => {
const rotation = (Math.random() * 360) as Degrees;
const rotatedPoint = pointRotateDegs(p, pointFrom(0, 0), rotation);
const rotatedPolyline = polyline.map((line) =>
lineSegmentRotate(line, degreesToRadians(rotation), pointFrom(0, 0)),
);
expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(true);
});
const falsePoints = [pointFrom(0, 1), pointFrom(2.1, 1.5)];
falsePoints.forEach((p) => {
const rotation = (Math.random() * 360) as Degrees;
const rotatedPoint = pointRotateDegs(p, pointFrom(0, 0), rotation);
const rotatedPolyline = polyline.map((line) =>
lineSegmentRotate(line, degreesToRadians(rotation), pointFrom(0, 0)),
);
expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(false);
});
});
});