Co-authored-by: dwelle <[email protected]>
@@ -1,7 +1,6 @@
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { type AppState } from "../../packages/excalidraw/types";
import { throttleRAF } from "../../packages/excalidraw/utils";
-import type { LineSegment } from "../../packages/utils";
import {
bootstrapCanvas,
getNormalizedCanvasDimensions,
@@ -13,12 +12,16 @@ import {
TrashIcon,
} from "../../packages/excalidraw/components/icons";
import { STORAGE_KEYS } from "../app_constants";
-import { isLineSegment } from "../../packages/excalidraw/element/typeChecks";
+import {
+ isLineSegment,
+ type GlobalPoint,
+ type LineSegment,
+} from "../../packages/math";
const renderLine = (
context: CanvasRenderingContext2D,
zoom: number,
- segment: LineSegment,
+ segment: LineSegment<GlobalPoint>,
color: string,
) => {
context.save();
@@ -47,10 +50,15 @@ const render = (
appState: AppState,
- frame.forEach((el) => {
+ frame.forEach((el: DebugElement) => {
switch (true) {
case isLineSegment(el.data):
- renderLine(context, appState.zoom.value, el.data, el.color);
+ renderLine(
+ context,
+ appState.zoom.value,
+ el.data as LineSegment<GlobalPoint>,
+ el.color,
+ );
break;
}
});
@@ -6,6 +6,7 @@
"excalidraw-app",
"packages/excalidraw",
"packages/utils",
+ "packages/math",
"examples/excalidraw",
"examples/excalidraw/*"
],
@@ -38,7 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import type { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor";
import { StoreAction } from "../store";
-import { clamp } from "../math";
+import { clamp } from "../../math";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@@ -42,20 +42,21 @@ export const actionDuplicateSelection = register({
perform: (elements, appState, formData, app) => {
// duplicate selected point(s) if editing a line
if (appState.editingLinearElement) {
- const ret = LinearElementEditor.duplicateSelectedPoints(
- appState,
- app.scene.getNonDeletedElementsMap(),
- );
+ // TODO: Invariants should be checked here instead of duplicateSelectedPoints()
+ try {
+ const newAppState = LinearElementEditor.duplicateSelectedPoints(
+ appState,
+ app.scene.getNonDeletedElementsMap(),
- if (!ret) {
+ return {
+ elements,
+ appState: newAppState,
+ storeAction: StoreAction.CAPTURE,
+ };
+ } catch {
return false;
-
- return {
- elements,
- appState: ret.appState,
- storeAction: StoreAction.CAPTURE,
- };
return {
@@ -6,7 +6,6 @@ import { done } from "../components/icons";
import { t } from "../i18n";
import { register } from "./register";
import { mutateElement } from "../element/mutateElement";
-import { isPathALoop } from "../math";
import { LinearElementEditor } from "../element/linearElementEditor";
maybeBindLinearElement,
@@ -16,6 +15,8 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
import type { AppState } from "../types";
import { resetCursor } from "../cursor";
+import { point } from "../../math";
+import { isPathALoop } from "../shapes";
export const actionFinalize = register({
name: "finalize",
@@ -112,10 +113,10 @@ export const actionFinalize = register({
const linePoints = multiPointElement.points;
const firstPoint = linePoints[0];
mutateElement(multiPointElement, {
- points: linePoints.map((point, index) =>
+ points: linePoints.map((p, index) =>
index === linePoints.length - 1
- ? ([firstPoint[0], firstPoint[1]] as const)
- : point,
+ ? point(firstPoint[0], firstPoint[1])
+ : p,
),
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
-import type { AppClassProperties, AppState, Point, Primitive } from "../types";
+import type { AppClassProperties, AppState, Primitive } from "../types";
import type { StoreActionType } from "../store";
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
@@ -115,6 +115,8 @@ import {
} from "../element/binding";
import { mutateElbowArrow } from "../element/routing";
+import type { LocalPoint } from "../../math";
+import { point, vector } from "../../math";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -1648,10 +1650,10 @@ export const actionChangeArrowType = register({
newElement,
elementsMap,
[finalStartPoint, finalEndPoint].map(
- (point) =>
- [point[0] - newElement.x, point[1] - newElement.y] as Point,
+ (p): LocalPoint =>
+ point(p[0] - newElement.x, p[1] - newElement.y),
- [0, 0],
+ vector(0, 0),
{
...(startElement && newElement.startBinding
? {
@@ -1,3 +1,5 @@
+import type { Radians } from "../math";
+import { point } from "../math";
COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX,
@@ -203,7 +205,7 @@ const chartXLabels = (
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
y: y + BAR_GAP / 2,
width: BAR_WIDTH,
- angle: 5.87,
+ angle: 5.87 as Radians,
fontSize: 16,
textAlign: "center",
verticalAlign: "top",
@@ -258,10 +260,7 @@ const chartLines = (
x,
y,
width: chartWidth,
- points: [
- [chartWidth, 0],
- ],
+ points: [point(0, 0), point(chartWidth, 0)],
const yLine = newLinearElement({
@@ -272,10 +271,7 @@ const chartLines = (
height: chartHeight,
- [0, -chartHeight],
+ points: [point(0, 0), point(0, -chartHeight)],
const maxLine = newLinearElement({
@@ -288,10 +284,7 @@ const chartLines = (
strokeStyle: "dotted",
opacity: GRID_OPACITY,
return [xLine, yLine, maxLine];
@@ -448,10 +441,7 @@ const chartTypeLine = (
height: cy,
- [0, cy],
+ points: [point(0, 0), point(0, cy)],
@@ -210,12 +210,6 @@ import {
isElementCompletelyInViewport,
isElementInViewport,
} from "../element/sizeHelpers";
-import {
- distance2d,
- getCornerRadius,
- getGridPoint,
- isPathALoop,
-} from "../math";
calculateScrollCenter,
getElementsWithinSelection,
@@ -230,7 +224,13 @@ import type {
ScrollBars,
} from "../scene/types";
import { getStateForZoom } from "../scene/zoom";
-import { findShapeByKey, getBoundTextShape, getElementShape } from "../shapes";
+ findShapeByKey,
+ getBoundTextShape,
+ getCornerRadius,
+ getElementShape,
+ isPathALoop,
+} from "../shapes";
import { getSelectionBoxShape } from "../../utils/geometry/shape";
import { isPointInShape } from "../../utils/collision";
import type {
@@ -386,6 +386,7 @@ import {
getReferenceSnapPoints,
SnapCache,
isGridModeEnabled,
+ getGridPoint,
} from "../snapping";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError";
@@ -439,6 +440,8 @@ import {
FlowChartNavigator,
getLinkDirectionFromKey,
} from "../element/flowchart";
+import type { LocalPoint, Radians } from "../../math";
+import { point, pointDistance, vector } from "../../math";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@@ -4844,7 +4847,7 @@ class App extends React.Component<AppProps, AppState> {
this.getElementHitThreshold(),
);
- return isPointInShape([x, y], selectionShape);
+ return isPointInShape(point(x, y), selectionShape);
// take bound text element into consideration for hit collision as well
@@ -5035,7 +5038,7 @@ class App extends React.Component<AppProps, AppState> {
containerId: shouldBindToContainer ? container?.id : undefined,
groupIds: container?.groupIds ?? [],
lineHeight,
- angle: container?.angle ?? 0,
+ angle: container?.angle ?? (0 as Radians),
frameId: topLayerFrame ? topLayerFrame.id : null,
@@ -5203,7 +5206,7 @@ class App extends React.Component<AppProps, AppState> {
element,
this.scene.getNonDeletedElementsMap(),
this.state,
- [scenePointer.x, scenePointer.y],
+ point(scenePointer.x, scenePointer.y),
this.device.editor.isMobile,
)
@@ -5214,11 +5217,12 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLCanvasElement>,
isTouchScreen: boolean,
- const draggedDistance = distance2d(
- this.lastPointerDownEvent!.clientX,
- this.lastPointerDownEvent!.clientY,
- this.lastPointerUpEvent!.clientX,
- this.lastPointerUpEvent!.clientY,
+ const draggedDistance = pointDistance(
+ point(
+ this.lastPointerDownEvent!.clientX,
+ this.lastPointerDownEvent!.clientY,
+ ),
+ point(this.lastPointerUpEvent!.clientX, this.lastPointerUpEvent!.clientY),
if (
!this.hitLinkElement ||
@@ -5237,7 +5241,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement,
- [lastPointerDownCoords.x, lastPointerDownCoords.y],
+ point(lastPointerDownCoords.x, lastPointerDownCoords.y),
const lastPointerUpCoords = viewportCoordsToSceneCoords(
@@ -5248,7 +5252,7 @@ class App extends React.Component<AppProps, AppState> {
- [lastPointerUpCoords.x, lastPointerUpCoords.y],
+ point(lastPointerUpCoords.x, lastPointerUpCoords.y),
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
@@ -5497,17 +5501,18 @@ class App extends React.Component<AppProps, AppState> {
// if we haven't yet created a temp point and we're beyond commit-zone
// threshold, add a point
- distance2d(
- scenePointerX - rx,
- scenePointerY - ry,
- lastPoint[0],
- lastPoint[1],
+ pointDistance(
+ point(scenePointerX - rx, scenePointerY - ry),
+ lastPoint,
) >= LINE_CONFIRM_THRESHOLD
) {
mutateElement(
multiElement,
- points: [...points, [scenePointerX - rx, scenePointerY - ry]],
+ points: [
+ ...points,
+ point<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
+ ],
},
false,
@@ -5519,11 +5524,9 @@ class App extends React.Component<AppProps, AppState> {
} else if (
points.length > 2 &&
lastCommittedPoint &&
- lastCommittedPoint[0],
- lastCommittedPoint[1],
+ lastCommittedPoint,
) < LINE_CONFIRM_THRESHOLD
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
@@ -5570,10 +5573,10 @@ class App extends React.Component<AppProps, AppState> {
[
...points.slice(0, -1),
- [
+ point<LocalPoint>(
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
undefined,
@@ -5589,10 +5592,10 @@ class App extends React.Component<AppProps, AppState> {
points: [
@@ -5817,17 +5820,15 @@ class App extends React.Component<AppProps, AppState> {
};
- const distance = distance2d(
- pointerDownState.lastCoords.x,
- pointerDownState.lastCoords.y,
- scenePointer.x,
- scenePointer.y,
+ const distance = pointDistance(
+ point(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
const threshold = this.getElementHitThreshold();
- const point = { ...pointerDownState.lastCoords };
+ const p = { ...pointerDownState.lastCoords };
let samplingInterval = 0;
while (samplingInterval <= distance) {
- const hitElements = this.getElementsAtPosition(point.x, point.y);
+ const hitElements = this.getElementsAtPosition(p.x, p.y);
processElements(hitElements);
// Exit since we reached current point
@@ -5839,12 +5840,10 @@ class App extends React.Component<AppProps, AppState> {
samplingInterval = Math.min(samplingInterval + threshold, distance);
const distanceRatio = samplingInterval / distance;
- const nextX =
- (1 - distanceRatio) * point.x + distanceRatio * scenePointer.x;
- const nextY =
- (1 - distanceRatio) * point.y + distanceRatio * scenePointer.y;
- point.x = nextX;
- point.y = nextY;
+ const nextX = (1 - distanceRatio) * p.x + distanceRatio * scenePointer.x;
+ const nextY = (1 - distanceRatio) * p.y + distanceRatio * scenePointer.y;
+ p.x = nextX;
+ p.y = nextY;
pointerDownState.lastCoords.x = scenePointer.x;
@@ -6325,7 +6324,7 @@ class App extends React.Component<AppProps, AppState> {
this.handleEmbeddableCenterClick(this.hitLinkElement);
@@ -7008,7 +7007,7 @@ class App extends React.Component<AppProps, AppState> {
simulatePressure,
locked: false,
- points: [[0, 0]],
+ points: [point<LocalPoint>(0, 0)],
pressures: simulatePressure ? [] : [event.pressure],
@@ -7216,11 +7215,9 @@ class App extends React.Component<AppProps, AppState> {
multiElement.points.length > 1 &&
- pointerDownState.origin.x - rx,
- pointerDownState.origin.y - ry,
+ point(pointerDownState.origin.x - rx, pointerDownState.origin.y - ry),
this.actionManager.executeAction(actionFinalize);
@@ -7321,7 +7318,7 @@ class App extends React.Component<AppProps, AppState> {
mutateElement(element, {
- points: [...element.points, [0, 0]],
+ points: [...element.points, point<LocalPoint>(0, 0)],
const boundElement = getHoveredElementForBinding(
pointerDownState.origin,
@@ -7573,11 +7570,9 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeTool.type === "line")
- pointerCoords.x,
- pointerCoords.y,
- pointerDownState.origin.x,
- pointerDownState.origin.y,
+ point(pointerCoords.x, pointerCoords.y),
+ point(pointerDownState.origin.x, pointerDownState.origin.y),
) < DRAGGING_THRESHOLD
return;
@@ -7926,7 +7921,7 @@ class App extends React.Component<AppProps, AppState> {
- points: [...points, [dx, dy]],
+ points: [...points, point<LocalPoint>(dx, dy)],
pressures,
@@ -7955,7 +7950,7 @@ class App extends React.Component<AppProps, AppState> {
@@ -7963,8 +7958,8 @@ class App extends React.Component<AppProps, AppState> {
mutateElbowArrow(
- [...points.slice(0, -1), [dx, dy]],
+ [...points.slice(0, -1), point<LocalPoint>(dx, dy)],
isDragging: true,
@@ -7975,7 +7970,7 @@ class App extends React.Component<AppProps, AppState> {
- points: [...points.slice(0, -1), [dx, dy]],
+ points: [...points.slice(0, -1), point<LocalPoint>(dx, dy)],
@@ -8284,9 +8279,9 @@ class App extends React.Component<AppProps, AppState> {
: [...newElement.pressures, childEvent.pressure];
mutateElement(newElement, {
- lastCommittedPoint: [dx, dy],
+ lastCommittedPoint: point<LocalPoint>(dx, dy),
@@ -8333,7 +8328,10 @@ class App extends React.Component<AppProps, AppState> {
...newElement.points,
- [pointerCoords.x - newElement.x, pointerCoords.y - newElement.y],
+ pointerCoords.x - newElement.x,
+ pointerCoords.y - newElement.y,
this.setState({
@@ -8643,11 +8641,9 @@ class App extends React.Component<AppProps, AppState> {
if (isEraserActive(this.state) && pointerStart && pointerEnd) {
this.eraserTrail.endPath();
- pointerStart.clientX,
- pointerStart.clientY,
- pointerEnd.clientX,
- pointerEnd.clientY,
+ point(pointerStart.clientX, pointerStart.clientY),
+ point(pointerEnd.clientX, pointerEnd.clientY),
if (draggedDistance === 0) {
@@ -2,13 +2,14 @@ import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
-import { degreeToRadian, radianToDegree } from "../../math";
import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
+import type { Degrees } from "../../../math";
+import { degreesToRadians, radiansToDegrees } from "../../../math";
interface AngleProps {
element: ExcalidrawElement;
@@ -36,7 +37,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
if (nextValue !== undefined) {
- const nextAngle = degreeToRadian(nextValue);
+ const nextAngle = degreesToRadians(nextValue as Degrees);
mutateElement(latestElement, {
angle: nextAngle,
@@ -51,7 +52,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
const originalAngleInDegrees =
- Math.round(radianToDegree(origElement.angle) * 100) / 100;
+ Math.round(radiansToDegrees(origElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
@@ -61,7 +62,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
- const nextAngle = degreeToRadian(nextAngleInDegrees);
+ const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
@@ -80,7 +81,7 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => {
<DragInput
label="A"
icon={angleIcon}
- value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
+ value={Math.round((radiansToDegrees(element.angle) % 360) * 100) / 100}
elements={[element]}
dragInputCallback={handleDegreeChange}
editable={isPropertyEditable(element, "angle")}
@@ -3,13 +3,14 @@ import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks";
import { isInGroup } from "../../groups";
import { getStepSizedValue, isPropertyEditable } from "./utils";
interface MultiAngleProps {
elements: readonly ExcalidrawElement[];
@@ -39,7 +40,7 @@ const handleDegreeChange: DragInputCallbackType<
for (const element of editableLatestIndividualElements) {
if (!element) {
@@ -71,7 +72,7 @@ const handleDegreeChange: DragInputCallbackType<
const originalElement = editableOriginalIndividualElements[i];
- Math.round(radianToDegree(originalElement.angle) * 100) / 100;
+ Math.round(radiansToDegrees(originalElement.angle) * 100) / 100;
@@ -81,7 +82,7 @@ const handleDegreeChange: DragInputCallbackType<
latestElement,
@@ -109,7 +110,7 @@ const MultiAngle = ({
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
const angles = editableLatestIndividualElements.map(
- (el) => Math.round((radianToDegree(el.angle) % 360) * 100) / 100,
+ (el) => Math.round((radiansToDegrees(el.angle) % 360) * 100) / 100,
const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
@@ -13,13 +13,14 @@ import type {
NonDeletedSceneElementsMap,
} from "../../element/types";
-import type { AppState, Point } from "../../types";
+import type { AppState } from "../../types";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit, resizeElement } from "./utils";
import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
+import { point, type GlobalPoint } from "../../../math";
interface MultiDimensionProps {
property: "width" | "height";
@@ -104,7 +105,7 @@ const resizeGroup = (
nextHeight: number,
initialHeight: number,
aspectRatio: number,
- anchor: Point,
+ anchor: GlobalPoint,
property: MultiDimensionProps["property"],
latestElements: ExcalidrawElement[],
originalElements: ExcalidrawElement[],
@@ -181,7 +182,7 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight,
initialHeight,
aspectRatio,
- [x1, y1],
+ point(x1, y1),
property,
latestElements,
originalElements,
@@ -286,7 +287,7 @@ const handleDimensionChange: DragInputCallbackType<
@@ -4,7 +4,6 @@ import type {
NonDeletedExcalidrawElement,
-import { rotate } from "../../math";
import StatsDragInput from "./DragInput";
@@ -14,6 +13,7 @@ import { useMemo } from "react";
import { getElementsInAtomicUnit, moveElement } from "./utils";
+import { point, pointRotateRads } from "../../../math";
interface MultiPositionProps {
property: "x" | "y";
@@ -43,11 +43,9 @@ const moveElements = (
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
- const [topLeftX, topLeftY] = rotate(
- origElement.x,
- origElement.y,
- cx,
- cy,
+ const [topLeftX, topLeftY] = pointRotateRads(
+ point(origElement.x, origElement.y),
+ point(cx, cy),
origElement.angle,
@@ -98,11 +96,9 @@ const moveGroupTo = (
latestElement.y + latestElement.height / 2,
- latestElement.x,
- latestElement.y,
+ point(latestElement.x, latestElement.y),
latestElement.angle,
@@ -174,11 +170,9 @@ const handlePositionChange: DragInputCallbackType<
@@ -246,7 +240,11 @@ const MultiPosition = ({
const [el] = elementsInUnit;
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
- const [topLeftX, topLeftY] = rotate(el.x, el.y, cx, cy, el.angle);
+ point(el.x, el.y),
+ el.angle,
return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
}),
@@ -1,10 +1,10 @@
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { getStepSizedValue, moveElement } from "./utils";
interface PositionProps {
@@ -32,11 +32,9 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
@@ -94,11 +92,9 @@ const Position = ({
scene,
appState,
}: PositionProps) => {
- element.x,
- element.y,
- element.x + element.width / 2,
- element.y + element.height / 2,
+ point(element.x, element.y),
+ point(element.x + element.width / 2, element.y + element.height / 2),
element.angle,
const value =
@@ -19,12 +19,13 @@ import type {
ExcalidrawLinearElement,
ExcalidrawTextElement,
-import { degreeToRadian, rotate } from "../../math";
import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
import { getCommonBounds, isTextElement } from "../../element";
import { API } from "../../tests/helpers/api";
import { actionGroup } from "../../actions";
+import { degreesToRadians, point, pointRotateRads } from "../../../math";
const { h } = window;
const mouse = new Pointer("mouse");
@@ -46,7 +47,9 @@ const testInputProperty = (
expect(input.value).toBe(initialValue.toString());
UI.updateInput(input, String(nextValue));
if (property === "angle") {
- expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
+ expect(element[property]).toBe(
+ degreesToRadians(Number(nextValue) as Degrees),
} else if (property === "fontSize" && isTextElement(element)) {
expect(element[property]).toBe(Number(nextValue));
} else if (property !== "fontSize") {
@@ -260,11 +263,9 @@ describe("stats for a generic element", () => {
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
- rectangle.x,
- rectangle.y,
+ point(rectangle.x, rectangle.y),
rectangle.angle,
@@ -281,11 +282,9 @@ describe("stats for a generic element", () => {
testInputProperty(rectangle, "angle", "A", 0, 45);
- let [newTopLeftX, newTopLeftY] = rotate(
+ let [newTopLeftX, newTopLeftY] = pointRotateRads(
@@ -294,11 +293,9 @@ describe("stats for a generic element", () => {
testInputProperty(rectangle, "angle", "A", 45, 66);
- [newTopLeftX, newTopLeftY] = rotate(
+ [newTopLeftX, newTopLeftY] = pointRotateRads(
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
@@ -313,11 +310,9 @@ describe("stats for a generic element", () => {
testInputProperty(rectangle, "width", "W", rectangle.width, 400);
@@ -325,11 +320,9 @@ describe("stats for a generic element", () => {
- let [currentTopLeftX, currentTopLeftY] = rotate(
+ let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
@@ -340,11 +333,9 @@ describe("stats for a generic element", () => {
- [currentTopLeftX, currentTopLeftY] = rotate(
+ [currentTopLeftX, currentTopLeftY] = pointRotateRads(
@@ -642,7 +633,7 @@ describe("stats for multiple elements", () => {
UI.updateInput(angle, "40");
- const angleInRadian = degreeToRadian(40);
+ const angleInRadian = degreesToRadians(40 as Degrees);
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
expect(text?.angle).toBeCloseTo(angleInRadian, 4);
expect(frame.angle).toBe(0);
+import type { Radians } from "../../../math";
bindOrUnbindLinearElements,
updateBoundElements,
@@ -30,7 +32,6 @@ import {
getElementsInGroup,
isInGroup,
} from "../../groups";
import { getFontString } from "../../utils";
@@ -229,23 +230,19 @@ export const moveElement = (
originalElement.x + originalElement.width / 2,
originalElement.y + originalElement.height / 2,
- originalElement.x,
- originalElement.y,
+ point(originalElement.x, originalElement.y),
originalElement.angle,
const changeInX = newTopLeftX - topLeftX;
const changeInY = newTopLeftY - topLeftY;
- const [x, y] = rotate(
- newTopLeftX,
- newTopLeftY,
- cx + changeInX,
- cy + changeInY,
- -originalElement.angle,
+ const [x, y] = pointRotateRads(
+ point(newTopLeftX, newTopLeftY),
+ point(cx + changeInX, cy + changeInY),
+ -originalElement.angle as Radians,
@@ -25,11 +25,11 @@ import type { BinaryFiles } from "../../types";
import { ArrowRightIcon } from "../icons";
import "./TTDDialog.scss";
-import { isFiniteNumber } from "../../utils";
import { atom, useAtom } from "jotai";
import { trackEvent } from "../../analytics";
import { InlineIcon } from "../InlineIcon";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
+import { isFiniteNumber } from "../../../math";
const MIN_PROMPT_LENGTH = 3;
const MAX_PROMPT_LENGTH = 1000;
@@ -1,4 +1,4 @@
-import type { AppState, ExcalidrawProps, Point, UIAppState } from "../../types";
+import type { AppState, ExcalidrawProps, UIAppState } from "../../types";
sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords,
@@ -36,6 +36,7 @@ import { trackEvent } from "../../analytics";
import { useAppProps, useExcalidrawAppState } from "../App";
import { isEmbeddableElement } from "../../element/typeChecks";
import { getLinkHandleFromCoords } from "./helpers";
const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85;
@@ -176,10 +177,12 @@ export const Hyperlink = ({
if (timeoutId) {
clearTimeout(timeoutId);
- const shouldHide = shouldHideLinkPopup(element, elementsMap, appState, [
- event.clientX,
- event.clientY,
- ]) as boolean;
+ const shouldHide = shouldHideLinkPopup(
+ element,
+ elementsMap,
+ point(event.clientX, event.clientY),
+ ) as boolean;
if (shouldHide) {
timeoutId = window.setTimeout(() => {
setAppState({ showHyperlinkPopup: false });
@@ -416,7 +419,7 @@ const shouldHideLinkPopup = (
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
- [clientX, clientY]: Point,
+ [clientX, clientY]: GlobalPoint,
): Boolean => {
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{ clientX, clientY },
+import type { GlobalPoint, Radians } from "../../../math";
import { MIME_TYPES } from "../../constants";
import type { Bounds } from "../../element/bounds";
import { getElementAbsoluteCoords } from "../../element/bounds";
@@ -6,9 +8,8 @@ import type {
ElementsMap,
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
-import type { AppState, Point, UIAppState } from "../../types";
+import type { AppState, UIAppState } from "../../types";
export const EXTERNAL_LINK_IMG = document.createElement("img");
EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
@@ -17,7 +18,7 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds,
- angle: number,
+ angle: Radians,
appState: Pick<UIAppState, "zoom">,
): Bounds => {
const size = DEFAULT_LINK_SIZE;
@@ -33,11 +34,9 @@ export const getLinkHandleFromCoords = (
const x = x2 + dashedLineMargin - centeringOffset;
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
- const [rotatedX, rotatedY] = rotate(
- x + linkWidth / 2,
- y + linkHeight / 2,
- centerX,
- centerY,
+ const [rotatedX, rotatedY] = pointRotateRads(
+ point(x + linkWidth / 2, y + linkHeight / 2),
+ point(centerX, centerY),
angle,
return [
@@ -52,7 +51,7 @@ export const isPointHittingLinkIcon = (
- [x, y]: Point,
+ [x, y]: GlobalPoint,
const threshold = 4 / appState.zoom.value;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@@ -73,7 +72,7 @@ export const isPointHittingLink = (
isMobile: boolean,
if (!element.link || appState.selectedElementIds[element.id]) {
@@ -86,5 +85,5 @@ export const isPointHittingLink = (
return true;
- return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]);
+ return isPointHittingLinkIcon(element, elementsMap, appState, point(x, y));
@@ -40,11 +40,7 @@ import {
import { getDefaultAppState } from "../appState";
import { bumpVersion } from "../element/mutateElement";
- getUpdatedTimestamp,
- isFiniteNumber,
- updateActiveTool,
-} from "../utils";
+import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getContainerElement } from "../element/textElement";
@@ -58,6 +54,8 @@ import {
getNormalizedGridStep,
getNormalizedZoom,
} from "../scene";
+import { isFiniteNumber, point } from "../../math";
type RestoredAppState = Omit<
AppState,
@@ -152,7 +150,7 @@ const restoreElementWithProperties = <
roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
opacity:
element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity,
- angle: element.angle || 0,
+ angle: element.angle || (0 as Radians),
x: extra.x ?? element.x ?? 0,
y: extra.y ?? element.y ?? 0,
strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
@@ -266,10 +264,7 @@ const restoreElement = (
let y = element.y;
let points = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
- ? [
- [element.width, element.height],
- ]
+ ? [point(0, 0), point(element.width, element.height)]
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {
@@ -293,14 +288,11 @@ const restoreElement = (
case "arrow": {
const { startArrowhead = null, endArrowhead = "arrow" } = element;
- let x = element.x;
- let y = element.y;
- let points = // migrate old arrow model to new one
+ let x: number | undefined = element.x;
+ let y: number | undefined = element.y;
+ let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
@@ -2,6 +2,7 @@ import { vi } from "vitest";
import type { ExcalidrawElementSkeleton } from "./transform";
import { convertToExcalidrawElements } from "./transform";
import type { ExcalidrawArrowElement } from "../element/types";
const opts = { regenerateIds: false };
@@ -911,10 +912,7 @@ describe("Test Transform", () => {
x: 111.262,
y: 57,
strokeWidth: 2,
- [272.985, 0],
+ points: [point(0, 0), point(272.985, 0)],
label: {
text: "How are you?",
fontSize: 20,
@@ -937,7 +935,7 @@ describe("Test Transform", () => {
x: 77.017,
y: 79,
+ points: [point(0, 0)],
text: "Friendship",
@@ -53,6 +53,7 @@ import { randomId } from "../random";
import { syncInvalidIndices } from "../fractionalIndex";
import { getLineHeight } from "../fonts";
import { isArrowElement } from "../element/typeChecks";
+import { point, type LocalPoint } from "../../math";
export type ValidLinearElement = {
type: "arrow" | "line";
@@ -417,7 +418,7 @@ const bindLinearElementToElement = (
const endPointIndex = linearElement.points.length - 1;
const delta = 0.5;
- const newPoints = cloneJSON(linearElement.points) as [number, number][];
+ const newPoints = cloneJSON<readonly LocalPoint[]>(linearElement.points);
// left to right so shift the arrow towards right
@@ -535,10 +536,7 @@ export const convertToExcalidrawElements = (
excalidrawElement = newLinearElement({
width,
height,
- [width, height],
+ points: [point(0, 0), point(width, height)],
...element,
@@ -551,10 +549,7 @@ export const convertToExcalidrawElements = (
endArrowhead: "arrow",
type: "arrow",
@@ -1,8 +1,8 @@
-import * as GA from "../ga";
-import * as GAPoint from "../gapoints";
-import * as GADirection from "../gadirections";
-import * as GALine from "../galines";
-import * as GATransform from "../gatransforms";
+import * as GA from "../../math/ga/ga";
+import * as GAPoint from "../../math/ga/gapoints";
+import * as GADirection from "../../math/ga/gadirections";
+import * as GALine from "../../math/ga/galines";
+import * as GATransform from "../../math/ga/gatransforms";
ExcalidrawBindableElement,
@@ -10,7 +10,6 @@ import type {
ExcalidrawRectangleElement,
ExcalidrawDiamondElement,
ExcalidrawEllipseElement,
- ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawFrameLikeElement,
ExcalidrawIframeLikeElement,
@@ -26,11 +25,12 @@ import type {
ExcalidrawElbowArrowElement,
FixedPoint,
SceneElementsMap,
+ ExcalidrawRectanguloidElement,
} from "./types";
import type { Bounds } from "./bounds";
-import { getElementAbsoluteCoords } from "./bounds";
-import type { AppState, Point } from "../types";
+import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
+import type { AppState } from "../types";
import { isPointOnShape } from "../../utils/collision";
import { getElementAtPosition } from "../scene";
@@ -51,17 +51,7 @@ import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
-import { getElementShape } from "../shapes";
- aabbForElement,
- clamp,
- distanceSq2d,
- getCenterForBounds,
- getCenterForElement,
- pointInsideBounds,
- pointToVector,
- rotatePoint,
+import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
compareHeading,
HEADING_DOWN,
@@ -72,7 +62,18 @@ import {
vectorToHeading,
type Heading,
} from "./heading";
-import { segmentIntersectRectangleElement } from "../../utils/geometry/geometry";
+ lineSegment,
+ point,
+ pointRotateRads,
+ vectorFromPoint,
+ pointFromPair,
+ pointDistanceSq,
+ clamp,
+} from "../../math";
+import { segmentIntersectRectangleElement } from "../../utils/geometry/shape";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@@ -649,7 +650,7 @@ export const updateBoundElements = (
update,
): update is NonNullable<{
index: number;
- point: Point;
+ point: LocalPoint;
isDragging?: boolean;
}> => update !== null,
@@ -695,14 +696,14 @@ const getSimultaneouslyUpdatedElementIds = (
export const getHeadingForElbowArrowSnap = (
- point: Readonly<Point>,
- otherPoint: Readonly<Point>,
+ p: Readonly<GlobalPoint>,
+ otherPoint: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null,
- origPoint: Point,
+ origPoint: GlobalPoint,
): Heading => {
- const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point));
+ const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
if (!bindableElement || !aabb) {
return otherPointHeading;
@@ -716,17 +717,23 @@ export const getHeadingForElbowArrowSnap = (
if (!distance) {
return vectorToHeading(
- pointToVector(point, getCenterForElement(bindableElement)),
+ vectorFromPoint(
+ p,
+ point<GlobalPoint>(
+ bindableElement.x + bindableElement.width / 2,
+ bindableElement.y + bindableElement.height / 2,
- const pointHeading = headingForPointFromElement(bindableElement, aabb, point);
+ const pointHeading = headingForPointFromElement(bindableElement, aabb, p);
return pointHeading;
const getDistanceForBinding = (
+ point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement,
@@ -745,89 +752,87 @@ const getDistanceForBinding = (
export const bindPointToSnapToElementOutline = (
bindableElement: ExcalidrawBindableElement | undefined,
-): Point => {
+): GlobalPoint => {
const aabb = bindableElement && aabbForElement(bindableElement);
if (bindableElement && aabb) {
// TODO: Dirty hacks until tangents are properly calculated
- const heading = headingForPointFromElement(bindableElement, aabb, point);
+ const heading = headingForPointFromElement(bindableElement, aabb, p);
const intersections = [
- ...intersectElementWithLine(
+ ...(intersectElementWithLine(
bindableElement,
- [point[0], point[1] - 2 * bindableElement.height],
- [point[0], point[1] + 2 * bindableElement.height],
+ point(p[0], p[1] - 2 * bindableElement.height),
+ point(p[0], p[1] + 2 * bindableElement.height),
FIXED_BINDING_DISTANCE,
- ),
+ ) ?? []),
- [point[0] - 2 * bindableElement.width, point[1]],
- [point[0] + 2 * bindableElement.width, point[1]],
+ point(p[0] - 2 * bindableElement.width, p[1]),
+ point(p[0] + 2 * bindableElement.width, p[1]),
const isVertical =
compareHeading(heading, HEADING_LEFT) ||
compareHeading(heading, HEADING_RIGHT);
const dist = Math.abs(
- distanceToBindableElement(bindableElement, point, elementsMap),
+ distanceToBindableElement(bindableElement, p, elementsMap),
const isInner = isVertical
? dist < bindableElement.width * -0.1
: dist < bindableElement.height * -0.1;
- intersections.sort(
- (a, b) => distanceSq2d(a, point) - distanceSq2d(b, point),
+ intersections.sort((a, b) => pointDistanceSq(a, p) - pointDistanceSq(b, p));
return isInner
? headingToMidBindPoint(otherPoint, bindableElement, aabb)
: intersections.filter((i) =>
isVertical
- ? Math.abs(point[1] - i[1]) < 0.1
- : Math.abs(point[0] - i[0]) < 0.1,
+ ? Math.abs(p[1] - i[1]) < 0.1
+ : Math.abs(p[0] - i[0]) < 0.1,
)[0] ?? point;
- return point;
+ return p;
const headingToMidBindPoint = (
- point: Point,
+ p: GlobalPoint,
aabb: Bounds,
const center = getCenterForBounds(aabb);
- const heading = vectorToHeading(pointToVector(point, center));
+ const heading = vectorToHeading(vectorFromPoint(p, center));
case compareHeading(heading, HEADING_UP):
- return rotatePoint(
- [(aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]],
+ return pointRotateRads(
+ point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
center,
bindableElement.angle,
case compareHeading(heading, HEADING_RIGHT):
- [aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1],
+ point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
case compareHeading(heading, HEADING_DOWN):
- [(aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]],
+ point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
default:
- [aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1],
+ point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
@@ -836,22 +841,25 @@ const headingToMidBindPoint = (
export const avoidRectangularCorner = (
element: ExcalidrawBindableElement,
- p: Point,
- const center = getCenterForElement(element);
- const nonRotatedPoint = rotatePoint(p, center, -element.angle);
+ const center = point<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y + element.height / 2,
+ const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
// Top left
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
- [element.x - FIXED_BINDING_DISTANCE, element.y],
+ return pointRotateRads<GlobalPoint>(
+ point(element.x - FIXED_BINDING_DISTANCE, element.y),
- [element.x, element.y - FIXED_BINDING_DISTANCE],
+ point(element.x, element.y - FIXED_BINDING_DISTANCE),
@@ -861,14 +869,14 @@ export const avoidRectangularCorner = (
// Bottom left
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
- [element.x, element.y + element.height + FIXED_BINDING_DISTANCE],
+ point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE),
- [element.x - FIXED_BINDING_DISTANCE, element.y + element.height],
+ point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
@@ -881,20 +889,20 @@ export const avoidRectangularCorner = (
nonRotatedPoint[0] - element.x <
element.width + FIXED_BINDING_DISTANCE
element.x + element.width,
element.y + element.height + FIXED_BINDING_DISTANCE,
element.x + element.width + FIXED_BINDING_DISTANCE,
element.y + element.height,
@@ -907,14 +915,14 @@ export const avoidRectangularCorner = (
- [element.x + element.width, element.y - FIXED_BINDING_DISTANCE],
+ point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE),
- [element.x + element.width + FIXED_BINDING_DISTANCE, element.y],
+ point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
@@ -925,12 +933,12 @@ export const avoidRectangularCorner = (
export const snapToMid = (
tolerance: number = 0.05,
const { x, y, width, height, angle } = element;
- const center = [x + width / 2 - 0.1, y + height / 2 - 0.1] as Point;
- const nonRotated = rotatePoint(p, center, -angle);
+ const center = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1);
+ const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance
@@ -943,22 +951,30 @@ export const snapToMid = (
nonRotated[1] < center[1] + verticalThrehsold
// LEFT
- return rotatePoint([x - FIXED_BINDING_DISTANCE, center[1]], center, angle);
+ point(x - FIXED_BINDING_DISTANCE, center[1]),
+ center,
+ angle,
nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold &&
nonRotated[0] < center[0] + horizontalThrehsold
// TOP
- return rotatePoint([center[0], y - FIXED_BINDING_DISTANCE], center, angle);
+ point(center[0], y - FIXED_BINDING_DISTANCE),
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold &&
// RIGHT
- [x + width + FIXED_BINDING_DISTANCE, center[1]],
+ point(x + width + FIXED_BINDING_DISTANCE, center[1]),
@@ -968,8 +984,8 @@ export const snapToMid = (
// DOWN
- [center[0], y + height + FIXED_BINDING_DISTANCE],
+ point(center[0], y + height + FIXED_BINDING_DISTANCE),
@@ -984,7 +1000,7 @@ const updateBoundPoint = (
binding: PointBinding | null | undefined,
-): Point | null => {
+): LocalPoint | null => {
binding == null ||
// We only need to update the other end if this is a 2 point line element
@@ -1006,15 +1022,15 @@ const updateBoundPoint = (
startOrEnd === "startBinding" ? "start" : "end",
).fixedPoint;
- const globalMidPoint = [
+ const globalMidPoint = point<GlobalPoint>(
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
- ] as Point;
- const global = [
+ const global = point<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height,
- const rotatedGlobal = rotatePoint(
+ const rotatedGlobal = pointRotateRads(
global,
globalMidPoint,
@@ -1040,7 +1056,7 @@ const updateBoundPoint = (
- let newEdgePoint: Point;
+ let newEdgePoint: GlobalPoint;
// The linear element was not originally pointing inside the bound shape,
// we can point directly at the focus point
@@ -1054,7 +1070,7 @@ const updateBoundPoint = (
binding.gap,
- if (intersections.length === 0) {
+ if (!intersections || intersections.length === 0) {
// This should never happen, since focusPoint should always be
// inside the element, but just in case, bail out
newEdgePoint = focusPointAbsolute;
@@ -1101,15 +1117,15 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement,
+ const globalMidPoint = point(
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
- const nonRotatedSnappedGlobalPoint = rotatePoint(
+ const nonRotatedSnappedGlobalPoint = pointRotateRads(
snappedPoint,
- -hoveredElement.angle,
- ) as Point;
+ -hoveredElement.angle as Radians,
fixedPoint: normalizeFixedPoint([
@@ -1320,8 +1336,9 @@ export const bindingBorderTest = (
const threshold = maxBindingGap(element, element.width, element.height);
const shape = getElementShape(element, elementsMap);
return (
- isPointOnShape([x, y], shape, threshold) ||
- (fullShape === true && pointInsideBounds([x, y], aabbForElement(element)))
+ isPointOnShape(point(x, y), shape, threshold) ||
+ (fullShape === true &&
+ pointInsideBounds(point(x, y), aabbForElement(element)))
@@ -1339,7 +1356,7 @@ export const maxBindingGap = (
export const distanceToBindableElement = (
+ point: GlobalPoint,
): number => {
switch (element.type) {
@@ -1359,19 +1376,13 @@ export const distanceToBindableElement = (
const distanceToRectangle = (
- element:
- | ExcalidrawRectangleElement
- | ExcalidrawTextElement
- | ExcalidrawFreeDrawElement
- | ExcalidrawImageElement
- | ExcalidrawIframeLikeElement
- | ExcalidrawFrameLikeElement,
+ element: ExcalidrawRectanguloidElement,
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
- point,
return Math.max(
@@ -1382,7 +1393,7 @@ const distanceToRectangle = (
const distanceToDiamond = (
element: ExcalidrawDiamondElement,
@@ -1396,7 +1407,7 @@ const distanceToDiamond = (
const distanceToEllipse = (
element: ExcalidrawEllipseElement,
const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap);
@@ -1405,7 +1416,7 @@ const distanceToEllipse = (
const ellipseParamsForTest = (
): [GA.Point, GA.Line] => {
@@ -1467,7 +1478,7 @@ const ellipseParamsForTest = (
// so we only need to perform hit tests for the positive quadrant.
const pointRelativeToElement = (
element: ExcalidrawElement,
- pointTuple: Point,
+ pointTuple: GlobalPoint,
): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple);
@@ -1516,9 +1527,9 @@ const coordsCenter = (
const determineFocusDistance = (
// Point on the line, in absolute coordinates
- a: Point,
+ a: GlobalPoint,
// Another point on the line, in absolute coordinates (closer to element)
- b: Point,
+ b: GlobalPoint,
const relateToCenter = relativizationToElementCenter(element, elementsMap);
@@ -1559,13 +1570,13 @@ const determineFocusPoint = (
// The oriented, relative distance from the center of `element` of the
// returned focusPoint
focus: number,
- adjecentPoint: Point,
+ adjecentPoint: GlobalPoint,
if (focus === 0) {
const center = coordsCenter(x1, y1, x2, y2);
- return GAPoint.toTuple(center);
+ return pointFromPair(GAPoint.toTuple(center));
const adjecentPointRel = GATransform.apply(
@@ -1589,7 +1600,9 @@ const determineFocusPoint = (
point = findFocusPointForEllipse(element, focus, adjecentPointRel);
- return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point));
+ return pointFromPair(
+ GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
// Returns 2 or 0 intersection points between line going through `a` and `b`
@@ -1597,15 +1610,15 @@ const determineFocusPoint = (
const intersectElementWithLine = (
// Another point on the line, in absolute coordinates
// If given, the element is inflated by this value
gap: number = 0,
-): Point[] => {
+): GlobalPoint[] | undefined => {
if (isRectangularElement(element)) {
- return segmentIntersectRectangleElement(element, [a, b], gap);
+ return segmentIntersectRectangleElement(element, lineSegment(a, b), gap);
@@ -1619,8 +1632,14 @@ const intersectElementWithLine = (
aRel,
gap,
- return intersections.map((point) =>
- GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
+ return intersections.map(
+ (point) =>
+ pointFromPair(
+ // pointFromArray(
+ // ,
+ // ),
@@ -2173,12 +2192,18 @@ export class BindableElement {
export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number],
-) => {
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
- [element.x + element.width * fixedX, element.y + element.height * fixedY],
- getCenterForElement(element),
+ element.x + element.width * fixedX,
+ element.y + element.height * fixedY,
@@ -2186,7 +2211,7 @@ export const getGlobalFixedPointForBindableElement = (
const getGlobalFixedPoints = (
arrow: ExcalidrawElbowArrowElement,
+): [GlobalPoint, GlobalPoint] => {
const startElement =
arrow.startBinding &&
(elementsMap.get(arrow.startBinding.elementId) as
@@ -2197,23 +2222,26 @@ const getGlobalFixedPoints = (
(elementsMap.get(arrow.endBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
- const startPoint: Point =
+ const startPoint =
startElement && arrow.startBinding
? getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement,
- : [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]];
- const endPoint: Point =
+ : point<GlobalPoint>(
+ arrow.x + arrow.points[0][0],
+ arrow.y + arrow.points[0][1],
+ const endPoint =
endElement && arrow.endBinding
arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement,
- : [
arrow.x + arrow.points[arrow.points.length - 1][0],
arrow.y + arrow.points[arrow.points.length - 1][1],
- ];
return [startPoint, endPoint];
import { ROUNDNESS } from "../constants";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
@@ -123,9 +125,9 @@ describe("getElementBounds", () => {
a: 0.6447741904932416,
- [0, 0] as [number, number],
- [67.33984375, 92.48828125] as [number, number],
- [-102.7890625, 52.15625] as [number, number],
+ point<LocalPoint>(0, 0),
+ point<LocalPoint>(67.33984375, 92.48828125),
+ point<LocalPoint>(-102.7890625, 52.15625),
} as ExcalidrawLinearElement;
@@ -7,10 +7,10 @@ import type {
ExcalidrawTextElementWithContainer,
-import { distance2d, rotate, rotatePoint } from "../math";
import rough from "roughjs/bin/rough";
+import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type { Drawable, Op } from "roughjs/bin/core";
import { generateRoughOptions } from "../scene/Shape";
isArrowElement,
@@ -22,9 +22,24 @@ import {
import { rescalePoints } from "../points";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
-import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
-import { arrayToMap } from "../utils";
+import { arrayToMap, invariant } from "../utils";
+import type {
+ Degrees,
+ GlobalPoint,
+ LineSegment,
+ LocalPoint,
+ Radians,
+ degreesToRadians,
+ pointDistance,
+ pointFromArray,
+import type { Mutable } from "../utility-types";
export type RectangleBox = {
x: number;
@@ -97,7 +112,11 @@ export class ElementBounds {
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
- rotate(x, y, cx - element.x, cy - element.y, element.angle),
+ pointRotateRads(
+ point(x, y),
+ point(cx - element.x, cy - element.y),
+ element.angle,
@@ -110,10 +129,26 @@ export class ElementBounds {
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
} else if (element.type === "diamond") {
- const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
- const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
- const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
- const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
+ const [x11, y11] = pointRotateRads(
+ point(cx, y1),
+ const [x12, y12] = pointRotateRads(
+ point(cx, y2),
+ const [x22, y22] = pointRotateRads(
+ point(x1, cy),
+ const [x21, y21] = pointRotateRads(
+ point(x2, cy),
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
@@ -128,10 +163,26 @@ export class ElementBounds {
const hh = Math.hypot(h * cos, w * sin);
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else {
- const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
- const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
- const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
- const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
+ point(x1, y2),
+ point(x2, y2),
+ point(x2, y1),
@@ -165,18 +216,18 @@ export const getElementAbsoluteCoords = (
? getContainerElement(element, elementsMap)
: null;
if (isArrowElement(container)) {
- const coords = LinearElementEditor.getBoundTextElementPosition(
+ const { x, y } = LinearElementEditor.getBoundTextElementPosition(
container,
element as ExcalidrawTextElementWithContainer,
- coords.x,
- coords.y,
- coords.x + element.width,
- coords.y + element.height,
- coords.x + element.width / 2,
- coords.y + element.height / 2,
+ x,
+ y,
+ x + element.width,
+ y + element.height,
+ x + element.width / 2,
+ y + element.height / 2,
@@ -198,38 +249,40 @@ export const getElementAbsoluteCoords = (
export const getElementLineSegments = (
-): [Point, Point][] => {
+): LineSegment<GlobalPoint>[] => {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
- const center: Point = [cx, cy];
+ const center: GlobalPoint = point(cx, cy);
if (isLinearElement(element) || isFreeDrawElement(element)) {
- const segments: [Point, Point][] = [];
+ const segments: LineSegment<GlobalPoint>[] = [];
let i = 0;
while (i < element.points.length - 1) {
- segments.push([
- rotatePoint(
- element.points[i][0] + element.x,
- element.points[i][1] + element.y,
- ] as Point,
- center,
- element.angle,
- element.points[i + 1][0] + element.x,
- element.points[i + 1][1] + element.y,
+ segments.push(
+ lineSegment(
+ element.points[i][0] + element.x,
+ element.points[i][1] + element.y,
+ element.points[i + 1][0] + element.x,
+ element.points[i + 1][1] + element.y,
- ]);
i++;
@@ -246,40 +299,40 @@ export const getElementLineSegments = (
[cx, y2],
[x1, cy],
[x2, cy],
- ] as Point[]
- ).map((point) => rotatePoint(point, center, element.angle));
+ ] as GlobalPoint[]
+ ).map((point) => pointRotateRads(point, center, element.angle));
if (element.type === "diamond") {
- [n, w],
- [n, e],
- [s, w],
- [s, e],
+ lineSegment(n, w),
+ lineSegment(n, e),
+ lineSegment(s, w),
+ lineSegment(s, e),
if (element.type === "ellipse") {
- [nw, ne],
- [sw, se],
- [nw, sw],
- [ne, se],
- [nw, e],
- [sw, e],
- [ne, w],
- [se, w],
+ lineSegment(nw, ne),
+ lineSegment(sw, se),
+ lineSegment(nw, sw),
+ lineSegment(ne, se),
+ lineSegment(nw, e),
+ lineSegment(sw, e),
+ lineSegment(ne, w),
+ lineSegment(se, w),
@@ -386,10 +439,10 @@ const solveQuadratic = (
const getCubicBezierCurveBound = (
- p0: Point,
- p1: Point,
- p2: Point,
- p3: Point,
+ p0: GlobalPoint,
+ p1: GlobalPoint,
+ p2: GlobalPoint,
+ p3: GlobalPoint,
const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
@@ -415,9 +468,9 @@ const getCubicBezierCurveBound = (
export const getMinMaxXYFromCurvePathOps = (
ops: Op[],
- transformXY?: (x: number, y: number) => [number, number],
+ transformXY?: (p: GlobalPoint) => GlobalPoint,
- let currentP: Point = [0, 0];
+ let currentP: GlobalPoint = point(0, 0);
const { minX, minY, maxX, maxY } = ops.reduce(
(limits, { op, data }) => {
@@ -425,19 +478,21 @@ export const getMinMaxXYFromCurvePathOps = (
// move, bcurveTo, lineTo, and curveTo
if (op === "move") {
// change starting point
- currentP = data as unknown as Point;
+ const p: GlobalPoint | undefined = pointFromArray(data);
+ invariant(p != null, "Op data is not a point");
+ currentP = p;
// move operation does not draw anything; so, it always
// returns false
} else if (op === "bcurveTo") {
- const _p1 = [data[0], data[1]] as Point;
- const _p2 = [data[2], data[3]] as Point;
- const _p3 = [data[4], data[5]] as Point;
+ const _p1 = point<GlobalPoint>(data[0], data[1]);
+ const _p2 = point<GlobalPoint>(data[2], data[3]);
+ const _p3 = point<GlobalPoint>(data[4], data[5]);
- const p1 = transformXY ? transformXY(..._p1) : _p1;
- const p2 = transformXY ? transformXY(..._p2) : _p2;
- const p3 = transformXY ? transformXY(..._p3) : _p3;
+ const p1 = transformXY ? transformXY(_p1) : _p1;
+ const p2 = transformXY ? transformXY(_p2) : _p2;
+ const p3 = transformXY ? transformXY(_p3) : _p3;
- const p0 = transformXY ? transformXY(...currentP) : currentP;
+ const p0 = transformXY ? transformXY(currentP) : currentP;
currentP = _p3;
const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
@@ -507,14 +562,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
/** @returns number in degrees */
-export const getArrowheadAngle = (arrowhead: Arrowhead): number => {
+export const getArrowheadAngle = (arrowhead: Arrowhead): Degrees => {
switch (arrowhead) {
case "bar":
- return 90;
+ return 90 as Degrees;
case "arrow":
- return 20;
+ return 20 as Degrees;
- return 25;
+ return 25 as Degrees;
@@ -533,19 +588,24 @@ export const getArrowheadPoints = (
const index = position === "start" ? 1 : ops.length - 1;
const data = ops[index].data;
- const p3 = [data[4], data[5]] as Point;
- const p2 = [data[2], data[3]] as Point;
- const p1 = [data[0], data[1]] as Point;
+
+ invariant(data.length === 6, "Op data length is not 6");
+ const p3 = point(data[4], data[5]);
+ const p2 = point(data[2], data[3]);
+ const p1 = point(data[0], data[1]);
// We need to find p0 of the bezier curve.
// It is typically the last point of the previous
// curve; it can also be the position of moveTo operation.
const prevOp = ops[index - 1];
- let p0: Point = [0, 0];
+ let p0 = point(0, 0);
if (prevOp.op === "move") {
- p0 = prevOp.data as unknown as Point;
+ const p = pointFromArray(prevOp.data);
+ p0 = p;
} else if (prevOp.op === "bcurveTo") {
- p0 = [prevOp.data[4], prevOp.data[5]];
+ p0 = point(prevOp.data[4], prevOp.data[5]);
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
@@ -610,8 +670,16 @@ export const getArrowheadPoints = (
const angle = getArrowheadAngle(arrowhead);
// Return points
- const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
- const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
+ const [x3, y3] = pointRotateRads(
+ point(xs, ys),
+ ((-angle * Math.PI) / 180) as Radians,
+ const [x4, y4] = pointRotateRads(
+ degreesToRadians(angle),
if (arrowhead === "diamond" || arrowhead === "diamond_outline") {
// point opposite to the arrowhead point
@@ -621,12 +689,10 @@ export const getArrowheadPoints = (
if (position === "start") {
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
- [ox, oy] = rotate(
- x2 + minSize * 2,
- y2,
- x2,
- Math.atan2(py - y2, px - x2),
+ [ox, oy] = pointRotateRads(
+ point(x2 + minSize * 2, y2),
+ Math.atan2(py - y2, px - x2) as Radians,
const [px, py] =
@@ -634,12 +700,10 @@ export const getArrowheadPoints = (
? element.points[element.points.length - 2]
: [0, 0];
- x2 - minSize * 2,
- Math.atan2(y2 - py, x2 - px),
+ point(x2 - minSize * 2, y2),
+ Math.atan2(y2 - py, x2 - px) as Radians,
@@ -665,7 +729,10 @@ const generateLinearElementShape = (
return "linearPath";
})();
- return generator[method](element.points as Mutable<Point>[], options);
+ return generator[method](
+ element.points as Mutable<LocalPoint>[] as RoughPoint[],
+ options,
const getLinearElementRotatedBounds = (
@@ -678,11 +745,9 @@ const getLinearElementRotatedBounds = (
if (element.points.length < 2) {
const [pointX, pointY] = element.points[0];
- element.x + pointX,
- element.y + pointY,
+ point(element.x + pointX, element.y + pointY),
@@ -708,8 +773,12 @@ const getLinearElementRotatedBounds = (
const cachedShape = ShapeCache.get(element)?.[0];
const shape = cachedShape ?? generateLinearElementShape(element);
const ops = getCurvePathOps(shape);
- const transformXY = (x: number, y: number) =>
- rotate(element.x + x, element.y + y, cx, cy, element.angle);
+ const transformXY = ([x, y]: GlobalPoint) =>
+ pointRotateRads<GlobalPoint>(
+ point(element.x + x, element.y + y),
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
let coords: Bounds = [res[0], res[1], res[2], res[3]];
if (boundTextElement) {
@@ -861,7 +930,10 @@ export const getClosestElementBounds = (
const elementsMap = arrayToMap(elements);
elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
- const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y);
+ point((x1 + x2) / 2, (y1 + y2) / 2),
+ point(from.x, from.y),
if (distance < minDistance) {
minDistance = distance;
@@ -916,3 +988,9 @@ export const getVisibleSceneBounds = ({
-scrollY + height / zoom.value,
+export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
+ bounds[0] + (bounds[2] - bounds[0]) / 2,
+ bounds[1] + (bounds[3] - bounds[1]) / 2,
@@ -1,14 +1,11 @@
-import { isPathALoop, isPointWithinBounds } from "../math";
ExcalidrawElement,
import { getElementBounds } from "./bounds";
import type { FrameNameBounds } from "../types";
-import type { Polygon, GeometricShape } from "../../utils/geometry/shape";
+import type { GeometricShape } from "../../utils/geometry/shape";
import { getPolygonShape } from "../../utils/geometry/shape";
import { isPointInShape, isPointOnShape } from "../../utils/collision";
import { isTransparent } from "../utils";
@@ -18,7 +15,9 @@ import {
isImageElement,
isTextElement,
} from "./typeChecks";
-import { getBoundTextShape } from "../shapes";
+import { getBoundTextShape, isPathALoop } from "../shapes";
+import type { GlobalPoint, LocalPoint, Polygon } from "../../math";
+import { isPointWithinBounds, point } from "../../math";
export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") {
@@ -42,35 +41,36 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
return isDraggableFromInside || isImageElement(element);
-export type HitTestArgs = {
+export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
y: number;
- shape: GeometricShape;
+ shape: GeometricShape<Point>;
threshold?: number;
frameNameBound?: FrameNameBounds | null;
-export const hitElementItself = ({
+export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
shape,
threshold = 10,
frameNameBound = null,
-}: HitTestArgs) => {
+}: 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([x, y], shape) || isPointOnShape([x, y], shape, threshold)
- : isPointOnShape([x, y], shape, threshold);
+ isPointInShape(point(x, y), shape) ||
+ isPointOnShape(point(x, y), shape, threshold)
+ : isPointOnShape(point(x, y), shape, threshold);
// hit test against a frame's name
if (!hit && frameNameBound) {
- hit = isPointInShape([x, y], {
+ hit = isPointInShape(point(x, y), {
type: "polygon",
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
- .data as Polygon,
+ .data as Polygon<Point>,
@@ -89,11 +89,13 @@ export const hitElementBoundingBox = (
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
- return isPointWithinBounds([x1, y1], [x, y], [x2, y2]);
+ return isPointWithinBounds(point(x1, y1), point(x, y), point(x2, y2));
-export const hitElementBoundingBoxOnly = (
- hitArgs: HitTestArgs,
+export const hitElementBoundingBoxOnly = <
+ Point extends GlobalPoint | LocalPoint,
+>(
+ hitArgs: HitTestArgs<Point>,
@@ -108,10 +110,10 @@ export const hitElementBoundingBoxOnly = (
-export const hitElementBoundText = (
+export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
x: number,
y: number,
- textShape: GeometricShape | null,
+ textShape: GeometricShape<Point> | null,
): boolean => {
- return !!textShape && isPointInShape([x, y], textShape);
+ return !!textShape && isPointInShape(point(x, y), textShape);
@@ -11,7 +11,6 @@ import type {
PointerDownState,
} from "../types";
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
-import { getGridPoint } from "../math";
import type Scene from "../scene/Scene";
@@ -21,6 +20,7 @@ import {
import { getFontString } from "../utils";
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
+import { getGridPoint } from "../snapping";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
@@ -10,7 +10,6 @@ import {
import { bindLinearElement } from "./binding";
import { newArrowElement, newElement } from "./newElement";
-import { aabbForElement } from "../math";
@@ -20,7 +19,7 @@ import type {
OrderedExcalidrawElement,
-import type { AppState, PendingExcalidrawElements, Point } from "../types";
+import type { AppState, PendingExcalidrawElements } from "../types";
import { mutateElement } from "./mutateElement";
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
@@ -30,6 +29,8 @@ import {
isFlowchartNodeElement,
import { invariant } from "../utils";
+import { aabbForElement } from "../shapes";
type LinkDirection = "up" | "right" | "down" | "left";
@@ -81,13 +82,14 @@ const getNodeRelatives = (
"not an ExcalidrawBindableElement",
- const edgePoint: Point =
- type === "predecessors" ? el.points[el.points.length - 1] : [0, 0];
+ const edgePoint = (
+ type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
+ ) as Readonly<LocalPoint>;
const heading = headingForPointFromElement(node, aabbForElement(node), [
edgePoint[0] + el.x,
edgePoint[1] + el.y,
+ ] as Readonly<LocalPoint>);
acc.push({
relative,
@@ -419,10 +421,7 @@ const createBindingArrow = (
strokeColor: appState.currentItemStrokeColor,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: appState.currentItemStrokeWidth,
- [endX, endY],
+ points: [point(0, 0), point(endX, endY)],
elbowed: true,
@@ -1,12 +1,18 @@
-import { lineAngle } from "../../utils/geometry/geometry";
-import type { Point, Vector } from "../../utils/geometry/shape";
+ Triangle,
+ Vector,
- PointInTriangle,
- scalePointFromOrigin,
-import type { Bounds } from "./bounds";
+ pointScaleFromOrigin,
+ radiansToDegrees,
+ triangleIncludesPoint,
+import { getCenterForBounds, type Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types";
export const HEADING_RIGHT = [1, 0] as Heading;
@@ -15,8 +21,13 @@ export const HEADING_LEFT = [-1, 0] as Heading;
export const HEADING_UP = [0, -1] as Heading;
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
-export const headingForDiamond = (a: Point, b: Point) => {
- const angle = lineAngle([a, b]);
+export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
+ a: Point,
+ b: Point,
+) => {
+ const angle = radiansToDegrees(
+ Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians,
if (angle >= 315 || angle < 45) {
return HEADING_UP;
} else if (angle >= 45 && angle < 135) {
@@ -47,56 +58,58 @@ export const compareHeading = (a: Heading, b: Heading) =>
// Gets the heading for the point by creating a bounding box around the rotated
// close fitting bounding box, then creating 4 search cones around the center of
// the external bbox.
-export const headingForPointFromElement = (
+export const headingForPointFromElement = <
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
+ p: Readonly<LocalPoint | GlobalPoint>,
const SEARCH_CONE_MULTIPLIER = 2;
const midPoint = getCenterForBounds(aabb);
- if (point[0] < element.x) {
+ if (p[0] < element.x) {
return HEADING_LEFT;
- } else if (point[1] < element.y) {
+ } else if (p[1] < element.y) {
- } else if (point[0] > element.x + element.width) {
+ } else if (p[0] > element.x + element.width) {
return HEADING_RIGHT;
- } else if (point[1] > element.y + element.height) {
+ } else if (p[1] > element.y + element.height) {
return HEADING_DOWN;
- const top = rotatePoint(
- scalePointFromOrigin(
- [element.x + element.width / 2, element.y],
+ const top = pointRotateRads(
+ pointScaleFromOrigin(
+ point(element.x + element.width / 2, element.y),
midPoint,
SEARCH_CONE_MULTIPLIER,
- const right = rotatePoint(
- [element.x + element.width, element.y + element.height / 2],
+ const right = pointRotateRads(
+ point(element.x + element.width, element.y + element.height / 2),
- const bottom = rotatePoint(
- [element.x + element.width / 2, element.y + element.height],
+ const bottom = pointRotateRads(
+ point(element.x + element.width / 2, element.y + element.height),
- const left = rotatePoint(
- [element.x, element.y + element.height / 2],
+ const left = pointRotateRads(
+ point(element.x, element.y + element.height / 2),
@@ -104,43 +117,62 @@ export const headingForPointFromElement = (
- if (PointInTriangle(point, top, right, midPoint)) {
+ if (triangleIncludesPoint([top, right, midPoint] as Triangle<Point>, p)) {
return headingForDiamond(top, right);
- } else if (PointInTriangle(point, right, bottom, midPoint)) {
+ } else if (
+ triangleIncludesPoint([right, bottom, midPoint] as Triangle<Point>, p)
+ ) {
return headingForDiamond(right, bottom);
- } else if (PointInTriangle(point, bottom, left, midPoint)) {
+ triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p)
return headingForDiamond(bottom, left);
return headingForDiamond(left, top);
- const topLeft = scalePointFromOrigin(
- [aabb[0], aabb[1]],
+ const topLeft = pointScaleFromOrigin(
+ point(aabb[0], aabb[1]),
- const topRight = scalePointFromOrigin(
- [aabb[2], aabb[1]],
+ ) as Point;
+ const topRight = pointScaleFromOrigin(
+ point(aabb[2], aabb[1]),
- const bottomLeft = scalePointFromOrigin(
- [aabb[0], aabb[3]],
+ const bottomLeft = pointScaleFromOrigin(
+ point(aabb[0], aabb[3]),
- const bottomRight = scalePointFromOrigin(
- [aabb[2], aabb[3]],
+ const bottomRight = pointScaleFromOrigin(
+ point(aabb[2], aabb[3]),
- return PointInTriangle(point, topLeft, topRight, midPoint)
+ return triangleIncludesPoint(
+ [topLeft, topRight, midPoint] as Triangle<Point>,
+ )
? HEADING_UP
- : PointInTriangle(point, topRight, bottomRight, midPoint)
+ : triangleIncludesPoint(
+ [topRight, bottomRight, midPoint] as Triangle<Point>,
? HEADING_RIGHT
- : PointInTriangle(point, bottomRight, bottomLeft, midPoint)
+ [bottomRight, bottomLeft, midPoint] as Triangle<Point>,
? HEADING_DOWN
: HEADING_LEFT;
+export const flipHeading = (h: Heading): Heading =>
+ [
+ h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1,
+ h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1,
+ ] as Heading;
@@ -11,19 +11,6 @@ import type {
FixedPointBinding,
- rotate,
- centerPoint,
- getControlPointsForBezierCurve,
- getBezierXY,
- getBezierCurveLength,
- mapIntervalToBezierT,
- arePointsEqual,
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
@@ -32,7 +19,6 @@ import {
getMinMaxXYFromCurvePathOps,
} from "./bounds";
- Point,
PointerCoords,
InteractiveCanvasAppState,
@@ -46,7 +32,7 @@ import {
getHoveredElementForBinding,
isBindingEnabled,
} from "./binding";
-import { toBrandedType, tupleToCoors } from "../utils";
+import { invariant, toBrandedType, tupleToCoors } from "../utils";
isBindingElement,
isElbowArrow,
@@ -60,10 +46,29 @@ import { ShapeCache } from "../scene/ShapeCache";
import type { Store } from "../store";
import { mutateElbowArrow } from "./routing";
+import type { Radians } from "../../math";
+ pointCenter,
+ pointsEqual,
+ vector,
+ type LocalPoint,
+ getBezierCurveLength,
+ getBezierXY,
+ getControlPointsForBezierCurve,
+ mapIntervalToBezierT,
const editorMidPointsCache: {
version: number | null;
- points: (Point | null)[];
+ points: (GlobalPoint | null)[];
zoom: number | null;
} = { version: null, points: [], zoom: null };
export class LinearElementEditor {
@@ -80,7 +85,7 @@ export class LinearElementEditor {
lastClickedIsEndPoint: boolean;
origin: Readonly<{ x: number; y: number }> | null;
segmentMidpoint: {
- value: Point | null;
+ value: GlobalPoint | null;
index: number | null;
added: boolean;
@@ -88,7 +93,7 @@ export class LinearElementEditor {
/** whether you're dragging a point */
public readonly isDragging: boolean;
- public readonly lastUncommittedPoint: Point | null;
+ public readonly lastUncommittedPoint: LocalPoint | null;
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
public readonly startBindingElement:
@@ -96,13 +101,13 @@ export class LinearElementEditor {
| "keep";
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
public readonly hoverPointIndex: number;
- public readonly segmentMidPointHoveredCoords: Point | null;
+ public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
constructor(element: NonDeleted<ExcalidrawLinearElement>) {
this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId";
- if (!arePointsEqual(element.points[0], [0, 0])) {
+ if (!pointsEqual(element.points[0], point(0, 0))) {
console.error("Linear element is not normalized", Error().stack);
@@ -280,7 +285,7 @@ export class LinearElementEditor {
referencePoint,
- [scenePointerX, scenePointerY],
+ point(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
@@ -289,7 +294,10 @@ export class LinearElementEditor {
index: selectedIndex,
- point: [width + referencePoint[0], height + referencePoint[1]],
+ point: point(
+ width + referencePoint[0],
+ height + referencePoint[1],
isDragging: selectedIndex === lastClickedPoint,
@@ -310,7 +318,7 @@ export class LinearElementEditor {
LinearElementEditor.movePoints(
selectedPointsIndices.map((pointIndex) => {
- const newPointPosition =
+ const newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
@@ -319,10 +327,10 @@ export class LinearElementEditor {
scenePointerY - linearElementEditor.pointerOffset.y,
- : ([
+ : point(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
- ] as const);
index: pointIndex,
point: newPointPosition,
@@ -515,7 +523,7 @@ export class LinearElementEditor {
let index = 0;
- const midpoints: (Point | null)[] = [];
+ const midpoints: (GlobalPoint | null)[] = [];
while (index < points.length - 1) {
LinearElementEditor.isSegmentTooShort(
@@ -549,7 +557,7 @@ export class LinearElementEditor {
scenePointer: { x: number; y: number },
- ) => {
+ ): GlobalPoint | null => {
const { elementId } = linearElementEditor;
const element = LinearElementEditor.getElement(elementId, elementsMap);
@@ -579,11 +587,12 @@ export class LinearElementEditor {
const existingSegmentMidpointHitCoords =
linearElementEditor.segmentMidPointHoveredCoords;
if (existingSegmentMidpointHitCoords) {
- existingSegmentMidpointHitCoords[0],
- existingSegmentMidpointHitCoords[1],
+ existingSegmentMidpointHitCoords[0],
+ existingSegmentMidpointHitCoords[1],
if (distance <= threshold) {
return existingSegmentMidpointHitCoords;
@@ -594,11 +603,9 @@ export class LinearElementEditor {
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
while (index < midPoints.length) {
if (midPoints[index] !== null) {
- midPoints[index]![0],
- midPoints[index]![1],
+ point(midPoints[index]![0], midPoints[index]![1]),
return midPoints[index];
@@ -612,15 +619,13 @@ export class LinearElementEditor {
static isSegmentTooShort(
element: NonDeleted<ExcalidrawLinearElement>,
- startPoint: Point,
- endPoint: Point,
+ startPoint: GlobalPoint | LocalPoint,
+ endPoint: GlobalPoint | LocalPoint,
zoom: AppState["zoom"],
- let distance = distance2d(
- startPoint[0],
- startPoint[1],
- endPoint[0],
- endPoint[1],
+ let distance = pointDistance(
+ point(startPoint[0], startPoint[1]),
+ point(endPoint[0], endPoint[1]),
if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint);
@@ -631,12 +636,12 @@ export class LinearElementEditor {
static getSegmentMidPoint(
+ startPoint: GlobalPoint,
+ endPoint: GlobalPoint,
endPointIndex: number,
- ) {
- let segmentMidPoint = centerPoint(startPoint, endPoint);
+ ): GlobalPoint {
+ let segmentMidPoint = pointCenter(startPoint, endPoint);
const controlPoints = getControlPointsForBezierCurve(
@@ -649,16 +654,15 @@ export class LinearElementEditor {
0.5,
- const [tx, ty] = getBezierXY(
- controlPoints[0],
- controlPoints[1],
- controlPoints[2],
- controlPoints[3],
- t,
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
- [tx, ty],
+ getBezierXY(
+ controlPoints[0],
+ controlPoints[1],
+ controlPoints[2],
+ controlPoints[3],
+ t,
@@ -670,7 +674,7 @@ export class LinearElementEditor {
static getSegmentMidPointIndex(
linearElementEditor: LinearElementEditor,
- midPoint: Point,
+ midPoint: GlobalPoint,
const element = LinearElementEditor.getElement(
@@ -822,11 +826,12 @@ export class LinearElementEditor {
const cy = (y1 + y2) / 2;
const targetPoint =
clickedPointIndex > -1 &&
- rotate(
- element.x + element.points[clickedPointIndex][0],
- element.y + element.points[clickedPointIndex][1],
+ element.x + element.points[clickedPointIndex][0],
+ element.y + element.points[clickedPointIndex][1],
@@ -865,14 +870,17 @@ export class LinearElementEditor {
return ret;
- static arePointsEqual(point1: Point | null, point2: Point | null) {
+ static arePointsEqual<Point extends LocalPoint | GlobalPoint>(
+ point1: Point | null,
+ point2: Point | null,
if (!point1 && !point2) {
if (!point1 || !point2) {
- return arePointsEqual(point1, point2);
+ return pointsEqual(point1, point2);
static handlePointerMove(
@@ -909,7 +917,7 @@ export class LinearElementEditor {
- let newPoint: Point;
+ let newPoint: LocalPoint;
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
const lastCommittedPoint = points[points.length - 2];
@@ -918,14 +926,14 @@ export class LinearElementEditor {
lastCommittedPoint,
- newPoint = [
+ newPoint = point(
width + lastCommittedPoint[0],
height + lastCommittedPoint[1],
newPoint = LinearElementEditor.createPointAt(
@@ -965,30 +973,36 @@ export class LinearElementEditor {
/** scene coords */
static getPointGlobalCoordinates(
+ p: LocalPoint,
const cx = (x1 + x2) / 2;
- let { x, y } = element;
- [x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
- return [x, y] as const;
+ const { x, y } = element;
+ point(x + p[0], y + p[1]),
static getPointsGlobalCoordinates(
- ): Point[] {
+ ): GlobalPoint[] {
- return element.points.map((point) => {
+ return element.points.map((p) => {
@@ -997,7 +1011,7 @@ export class LinearElementEditor {
indexMaybeFromEnd: number, // -1 for last element
- ): Point {
const index =
indexMaybeFromEnd < 0
? element.points.length + indexMaybeFromEnd
@@ -1005,35 +1019,36 @@ export class LinearElementEditor {
- const point = element.points[index];
+ const p = element.points[index];
const { x, y } = element;
- return point
- ? rotate(x + point[0], y + point[1], cx, cy, element.angle)
- : rotate(x, y, cx, cy, element.angle);
+ return p
+ ? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle)
+ : pointRotateRads(point(x, y), point(cx, cy), element.angle);
static pointFromAbsoluteCoords(
- absoluteCoords: Point,
+ absoluteCoords: GlobalPoint,
+ ): LocalPoint {
if (isElbowArrow(element)) {
// No rotation for elbow arrows
- return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
+ return point(
+ absoluteCoords[0] - element.x,
+ absoluteCoords[1] - element.y,
- absoluteCoords[0],
- absoluteCoords[1],
- -element.angle,
+ point(absoluteCoords[0], absoluteCoords[1]),
+ -element.angle as Radians,
- return [x - element.x, y - element.y];
+ return point(x - element.x, y - element.y);
static getPointIndexUnderCursor(
@@ -1052,9 +1067,9 @@ export class LinearElementEditor {
// points on the left, thus should take precedence when clicking, if they
// overlap
while (--idx > -1) {
- const point = pointHandles[idx];
+ const p = pointHandles[idx];
- distance2d(x, y, point[0], point[1]) * zoom.value <
+ pointDistance(point(x, y), point(p[0], p[1])) * zoom.value <
// +1px to account for outline stroke
LinearElementEditor.POINT_HANDLE_SIZE + 1
@@ -1070,20 +1085,18 @@ export class LinearElementEditor {
scenePointerX: number,
scenePointerY: number,
gridSize: NullableGridSize,
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
- pointerOnGrid[0],
- pointerOnGrid[1],
+ point(pointerOnGrid[0], pointerOnGrid[1]),
- return [rotatedX - element.x, rotatedY - element.y];
+ return point(rotatedX - element.x, rotatedY - element.y);
/**
@@ -1091,15 +1104,19 @@ export class LinearElementEditor {
* expected in various parts of the codebase. Also returns new x/y to account
* for the potential normalization.
*/
- static getNormalizedPoints(element: ExcalidrawLinearElement) {
+ static getNormalizedPoints(element: ExcalidrawLinearElement): {
+ points: LocalPoint[];
+ x: number;
+ y: number;
+ } {
const { points } = element;
const offsetX = points[0][0];
const offsetY = points[0][1];
- points: points.map((point) => {
- return [point[0] - offsetX, point[1] - offsetY] as const;
+ points: points.map((p) => {
+ return point(p[0] - offsetX, p[1] - offsetY);
x: element.x + offsetX,
y: element.y + offsetY,
@@ -1116,17 +1133,23 @@ export class LinearElementEditor {
static duplicateSelectedPoints(
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
- if (!appState.editingLinearElement) {
- return false;
- }
+ ): AppState {
+ invariant(
+ appState.editingLinearElement,
+ "Not currently editing a linear element",
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
- if (!element || selectedPointsIndices === null) {
+ "The linear element does not exist in the provided Scene",
+ selectedPointsIndices != null,
+ "There are no selected points to duplicate",
@@ -1134,9 +1157,9 @@ export class LinearElementEditor {
let pointAddedToEnd = false;
let indexCursor = -1;
- const nextPoints = points.reduce((acc: Point[], point, index) => {
+ const nextPoints = points.reduce((acc: LocalPoint[], p, index) => {
++indexCursor;
- acc.push(point);
+ acc.push(p);
const isSelected = selectedPointsIndices.includes(index);
if (isSelected) {
@@ -1147,8 +1170,8 @@ export class LinearElementEditor {
acc.push(
nextPoint
- ? [(point[0] + nextPoint[0]) / 2, (point[1] + nextPoint[1]) / 2]
- : [point[0], point[1]],
+ ? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
+ : point(p[0], p[1]),
nextSelectedIndices.push(indexCursor + 1);
@@ -1169,7 +1192,7 @@ export class LinearElementEditor {
index: element.points.length - 1,
- point: [lastPoint[0] + 30, lastPoint[1] + 30],
+ point: point(lastPoint[0] + 30, lastPoint[1] + 30),
@@ -1177,12 +1200,10 @@ export class LinearElementEditor {
- appState: {
- ...appState,
- editingLinearElement: {
- ...appState.editingLinearElement,
- selectedPointsIndices: nextSelectedIndices,
- },
+ ...appState,
+ editingLinearElement: {
+ ...appState.editingLinearElement,
+ selectedPointsIndices: nextSelectedIndices,
@@ -1209,10 +1230,10 @@ export class LinearElementEditor {
- const nextPoints = element.points.reduce((acc: Point[], point, idx) => {
+ const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
if (!pointIndices.includes(idx)) {
- !acc.length ? [0, 0] : [point[0] - offsetX, point[1] - offsetY],
+ !acc.length ? point(0, 0) : point(p[0] - offsetX, p[1] - offsetY),
return acc;
@@ -1229,7 +1250,7 @@ export class LinearElementEditor {
static addPoints(
- targetPoints: { point: Point }[],
+ targetPoints: { point: LocalPoint }[],
const offsetX = 0;
@@ -1247,7 +1268,7 @@ export class LinearElementEditor {
static movePoints(
- targetPoints: { index: number; point: Point; isDragging?: boolean }[],
+ targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
otherUpdates?: {
startBinding?: PointBinding | null;
@@ -1277,11 +1298,11 @@ export class LinearElementEditor {
selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
- const nextPoints = points.map((point, idx) => {
- const selectedPointData = targetPoints.find((p) => p.index === idx);
+ const nextPoints: LocalPoint[] = points.map((p, idx) => {
+ const selectedPointData = targetPoints.find((t) => t.index === idx);
if (selectedPointData) {
if (selectedPointData.index === 0) {
const deltaX =
@@ -1289,14 +1310,9 @@ export class LinearElementEditor {
const deltaY =
selectedPointData.point[1] - points[selectedPointData.index][1];
- return [
- point[0] + deltaX - offsetX,
- point[1] + deltaY - offsetY,
- ] as const;
+ return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
- return offsetX || offsetY
- ? ([point[0] - offsetX, point[1] - offsetY] as const)
- : point;
+ return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p;
LinearElementEditor._updatePoints(
@@ -1349,11 +1365,9 @@ export class LinearElementEditor {
const origin = linearElementEditor.pointerDownState.origin!;
- const dist = distance2d(
- origin.x,
- origin.y,
+ const dist = pointDistance(
+ point(origin.x, origin.y),
!appState.editingLinearElement &&
@@ -1418,7 +1432,7 @@ export class LinearElementEditor {
private static _updatePoints(
- nextPoints: readonly Point[],
+ nextPoints: readonly LocalPoint[],
offsetX: number,
offsetY: number,
@@ -1461,7 +1475,7 @@ export class LinearElementEditor {
mergedElementsMap,
nextPoints,
- [offsetX, offsetY],
+ vector(offsetX, offsetY),
bindings,
options,
@@ -1474,7 +1488,11 @@ export class LinearElementEditor {
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY;
- const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
+ const rotated = pointRotateRads(
+ point(offsetX, offsetY),
+ point(dX, dY),
...otherUpdates,
points: nextPoints,
@@ -1487,8 +1505,8 @@ export class LinearElementEditor {
private static _getShiftLockedDelta(
- referencePoint: Point,
- scenePointer: Point,
+ referencePoint: LocalPoint,
+ scenePointer: GlobalPoint,
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
@@ -1517,7 +1535,11 @@ export class LinearElementEditor {
gridY,
- return rotatePoint([width, height], [0, 0], -element.angle);
+ point(width, height),
+ point(0, 0),
static getBoundTextElementPosition = (
@@ -1548,7 +1570,7 @@ export class LinearElementEditor {
let midSegmentMidpoint = editorMidPointsCache.points[index];
if (element.points.length === 2) {
- midSegmentMidpoint = centerPoint(points[0], points[1]);
+ midSegmentMidpoint = pointCenter(points[0], points[1]);
!midSegmentMidpoint ||
@@ -1585,37 +1607,38 @@ export class LinearElementEditor {
const boundTextX2 = boundTextX1 + boundTextElement.width;
const boundTextY2 = boundTextY1 + boundTextElement.height;
+ const centerPoint = point(cx, cy);
- const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle);
- const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle);
- const counterRotateBoundTextTopLeft = rotatePoint(
- [boundTextX1, boundTextY1],
- [cx, cy],
+ const topLeftRotatedPoint = pointRotateRads(
+ centerPoint,
- const counterRotateBoundTextTopRight = rotatePoint(
- [boundTextX2, boundTextY1],
+ const topRightRotatedPoint = pointRotateRads(
- const counterRotateBoundTextBottomLeft = rotatePoint(
- [boundTextX1, boundTextY2],
+ const counterRotateBoundTextTopLeft = pointRotateRads(
+ point(boundTextX1, boundTextY1),
- const counterRotateBoundTextBottomRight = rotatePoint(
- [boundTextX2, boundTextY2],
+ const counterRotateBoundTextTopRight = pointRotateRads(
+ point(boundTextX2, boundTextY1),
+ const counterRotateBoundTextBottomLeft = pointRotateRads(
+ point(boundTextX1, boundTextY2),
+ const counterRotateBoundTextBottomRight = pointRotateRads(
+ point(boundTextX2, boundTextY2),
@@ -2,7 +2,6 @@ import type { ExcalidrawElement } from "./types";
import Scene from "../scene/Scene";
import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random";
-import type { Point } from "../types";
import { getUpdatedTimestamp } from "../utils";
import type { Mutable } from "../utility-types";
@@ -59,8 +58,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
let didChangePoints = false;
let index = prevPoints.length;
while (--index) {
- const prevPoint: Point = prevPoints[index];
- const nextPoint: Point = nextPoints[index];
+ const prevPoint = prevPoints[index];
+ const nextPoint = nextPoints[index];
prevPoint[0] !== nextPoint[0] ||
prevPoint[1] !== nextPoint[1]
@@ -4,6 +4,8 @@ import { API } from "../tests/helpers/api";
import { FONT_FAMILY, ROUNDNESS } from "../constants";
import { isPrimitive } from "../utils";
import type { ExcalidrawLinearElement } from "./types";
const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) {
@@ -36,10 +38,7 @@ describe("duplicating single elements", () => {
element.__proto__ = { hello: "world" };
- [1, 2],
- [3, 4],
+ points: [point<LocalPoint>(1, 2), point<LocalPoint>(3, 4)],
const copy = duplicateElement(null, new Map(), element);
@@ -30,7 +30,6 @@ import { bumpVersion, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups";
import { getElementAbsoluteCoords } from ".";
-import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
measureText,
@@ -48,6 +47,7 @@ import {
} from "../constants";
import type { MarkOptional, Merge, Mutable } from "../utility-types";
export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -88,7 +88,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
opacity = DEFAULT_ELEMENT_PROPS.opacity,
width = 0,
height = 0,
- angle = 0,
+ angle = 0 as Radians,
groupIds = [],
frameId = null,
index = null,
@@ -348,6 +348,53 @@ const getAdjustedDimensions = (
+const adjustXYWithRotation = (
+ sides: {
+ n?: boolean;
+ e?: boolean;
+ s?: boolean;
+ w?: boolean;
+ },
+ x: number,
+ y: number,
+ angle: number,
+ deltaX1: number,
+ deltaY1: number,
+ deltaX2: number,
+ deltaY2: number,
+): [number, number] => {
+ const cos = Math.cos(angle);
+ const sin = Math.sin(angle);
+ if (sides.e && sides.w) {
+ x += deltaX1 + deltaX2;
+ } else if (sides.e) {
+ x += deltaX1 * (1 + cos);
+ y += deltaX1 * sin;
+ x += deltaX2 * (1 - cos);
+ y += deltaX2 * -sin;
+ } else if (sides.w) {
+ x += deltaX1 * (1 - cos);
+ y += deltaX1 * -sin;
+ x += deltaX2 * (1 + cos);
+ y += deltaX2 * sin;
+ }
+ if (sides.n && sides.s) {
+ y += deltaY1 + deltaY2;
+ } else if (sides.n) {
+ x += deltaY1 * sin;
+ y += deltaY1 * (1 - cos);
+ x += deltaY2 * -sin;
+ y += deltaY2 * (1 + cos);
+ } else if (sides.s) {
+ x += deltaY1 * -sin;
+ y += deltaY1 * (1 + cos);
+ x += deltaY2 * sin;
+ y += deltaY2 * (1 - cos);
+ return [x, y];
+};
export const refreshTextDimensions = (
textElement: ExcalidrawTextElement,
container: ExcalidrawTextContainer | null,
@@ -1,7 +1,5 @@
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
-import { rotate, centerPoint, rotatePoint } from "../math";
@@ -38,7 +36,7 @@ import type {
MaybeTransformHandleType,
TransformHandleDirection,
} from "./transformHandles";
-import type { Point, PointerDownState } from "../types";
+import type { PointerDownState } from "../types";
getApproxMinLineWidth,
@@ -55,16 +53,15 @@ import {
import { isInGroup } from "../groups";
-export const normalizeAngle = (angle: number): number => {
- if (angle < 0) {
- return angle + 2 * Math.PI;
- if (angle >= 2 * Math.PI) {
- return angle - 2 * Math.PI;
- return angle;
-};
+import type { GlobalPoint } from "../../math";
+ normalizeRadians,
+ type Radians,
// Returns true when transform (resizing/rotation) happened
export const transformElements = (
@@ -158,16 +155,17 @@ const rotateSingleElement = (
- let angle: number;
+ let angle: Radians;
if (isFrameLikeElement(element)) {
- angle = 0;
+ angle = 0 as Radians;
- angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
+ angle = ((5 * Math.PI) / 2 +
+ Math.atan2(pointerY - cy, pointerX - cx)) as Radians;
if (shouldRotateWithDiscreteAngle) {
- angle += SHIFT_LOCKING_ANGLE / 2;
- angle -= angle % SHIFT_LOCKING_ANGLE;
+ angle = (angle + SHIFT_LOCKING_ANGLE / 2) as Radians;
+ angle = (angle - (angle % SHIFT_LOCKING_ANGLE)) as Radians;
- angle = normalizeAngle(angle);
+ angle = normalizeRadians(angle as Radians);
const boundTextElementId = getBoundTextElementId(element);
@@ -240,12 +238,10 @@ const resizeSingleTextElement = (
// rotation pointer with reverse angle
- pointerX,
- pointerY,
+ point(pointerX, pointerY),
let scaleX = 0;
let scaleY = 0;
@@ -279,20 +275,26 @@ const resizeSingleTextElement = (
const startBottomRight = [x2, y2];
const startCenter = [cx, cy];
- let newTopLeft = [x1, y1] as [number, number];
+ let newTopLeft = point<GlobalPoint>(x1, y1);
if (["n", "w", "nw"].includes(transformHandleType)) {
- newTopLeft = [
+ newTopLeft = point<GlobalPoint>(
startBottomRight[0] - Math.abs(nextWidth),
startBottomRight[1] - Math.abs(nextHeight),
if (transformHandleType === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
- newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(nextHeight)];
+ bottomLeft[0],
+ bottomLeft[1] - Math.abs(nextHeight),
if (transformHandleType === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]];
- newTopLeft = [topRight[0] - Math.abs(nextWidth), topRight[1]];
+ topRight[0] - Math.abs(nextWidth),
+ topRight[1],
if (["s", "n"].includes(transformHandleType)) {
@@ -308,13 +310,17 @@ const resizeSingleTextElement = (
const angle = element.angle;
- const rotatedTopLeft = rotatePoint(newTopLeft, [cx, cy], angle);
- const newCenter: Point = [
+ const rotatedTopLeft = pointRotateRads(newTopLeft, point(cx, cy), angle);
+ const newCenter = point<GlobalPoint>(
newTopLeft[0] + Math.abs(nextWidth) / 2,
newTopLeft[1] + Math.abs(nextHeight) / 2,
- const rotatedNewCenter = rotatePoint(newCenter, [cx, cy], angle);
- newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
+ const rotatedNewCenter = pointRotateRads(newCenter, point(cx, cy), angle);
+ newTopLeft = pointRotateRads(
+ rotatedTopLeft,
+ rotatedNewCenter,
+ -angle as Radians,
const [nextX, nextY] = newTopLeft;
@@ -334,14 +340,14 @@ const resizeSingleTextElement = (
stateAtResizeStart.height,
true,
- const startTopLeft: Point = [x1, y1];
- const startBottomRight: Point = [x2, y2];
- const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
+ const startTopLeft = point<GlobalPoint>(x1, y1);
+ const startBottomRight = point<GlobalPoint>(x2, y2);
+ const startCenter = pointCenter(startTopLeft, startBottomRight);
- const rotatedPointer = rotatePoint(
- [pointerX, pointerY],
+ const rotatedPointer = pointRotateRads(
startCenter,
- -stateAtResizeStart.angle,
+ -stateAtResizeStart.angle as Radians,
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
@@ -407,13 +413,21 @@ const resizeSingleTextElement = (
// adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle;
- const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
+ const rotatedTopLeft = pointRotateRads(
+ pointFromPair(newTopLeft),
+ startCenter,
+ const newCenter = point(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
- const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
+ const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
const resizedElement: Partial<ExcalidrawTextElement> = {
width: Math.abs(newWidth),
@@ -446,15 +460,15 @@ export const resizeSingleElement = (
+ const startTopLeft = point(x1, y1);
+ const startBottomRight = point(x2, y2);
// Calculate new dimensions based on cursor position
// Get bounds corners rendered on screen
@@ -628,13 +642,21 @@ export const resizeSingleElement = (
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
// So we need to readjust (x,y) to be where the first point should be
@@ -793,21 +815,21 @@ export const resizeMultipleElements = (
const direction = transformHandleType;
- const anchorsMap: Record<TransformHandleDirection, Point> = {
- ne: [minX, maxY],
- se: [minX, minY],
- sw: [maxX, minY],
- nw: [maxX, maxY],
- e: [minX, minY + height / 2],
- w: [maxX, minY + height / 2],
- n: [minX + width / 2, maxY],
- s: [minX + width / 2, minY],
+ const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = {
+ ne: point(minX, maxY),
+ se: point(minX, minY),
+ sw: point(maxX, minY),
+ nw: point(maxX, maxY),
+ e: point(minX, minY + height / 2),
+ w: point(maxX, minY + height / 2),
+ n: point(minX + width / 2, maxY),
+ s: point(minX + width / 2, minY),
// anchor point must be on the opposite side of the dragged selection handle
// or be the center of the selection if shouldResizeFromCenter
- const [anchorX, anchorY]: Point = shouldResizeFromCenter
- ? [midX, midY]
+ const [anchorX, anchorY] = shouldResizeFromCenter
+ ? point(midX, midY)
: anchorsMap[direction];
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
@@ -898,7 +920,9 @@ export const resizeMultipleElements = (
const width = orig.width * scaleX;
const height = orig.height * scaleY;
- const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
+ const angle = normalizeRadians(
+ (orig.angle * flipFactorX * flipFactorY) as Radians,
const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
const offsetX = orig.x - anchorX;
@@ -1029,12 +1053,10 @@ const rotateMultipleElements = (
const origAngle =
originalElements.get(element.id)?.angle ?? element.angle;
- const [rotatedCX, rotatedCY] = rotate(
- centerAngle + origAngle - element.angle,
+ const [rotatedCX, rotatedCY] = pointRotateRads(
+ (centerAngle + origAngle - element.angle) as Radians,
if (isArrowElement(element) && isElbowArrow(element)) {
@@ -1046,7 +1068,7 @@ const rotateMultipleElements = (
x: element.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy),
- angle: normalizeAngle(centerAngle + origAngle),
+ angle: normalizeRadians((centerAngle + origAngle) as Radians),
@@ -1063,7 +1085,7 @@ const rotateMultipleElements = (
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
@@ -1086,25 +1108,43 @@ export const getResizeOffsetXY = (
: getCommonBounds(selectedElements);
- const angle = selectedElements.length === 1 ? selectedElements[0].angle : 0;
- [x, y] = rotate(x, y, cx, cy, -angle);
+ const angle = (
+ selectedElements.length === 1 ? selectedElements[0].angle : 0
+ ) as Radians;
+ [x, y] = pointRotateRads(point(x, y), point(cx, cy), -angle as Radians);
switch (transformHandleType) {
case "n":
- return rotate(x - (x1 + x2) / 2, y - y1, 0, 0, angle);
+ point(x - (x1 + x2) / 2, y - y1),
case "s":
- return rotate(x - (x1 + x2) / 2, y - y2, 0, 0, angle);
+ point(x - (x1 + x2) / 2, y - y2),
case "w":
- return rotate(x - x1, y - (y1 + y2) / 2, 0, 0, angle);
+ point(x - x1, y - (y1 + y2) / 2),
case "e":
- return rotate(x - x2, y - (y1 + y2) / 2, 0, 0, angle);
+ point(x - x2, y - (y1 + y2) / 2),
case "nw":
- return rotate(x - x1, y - y1, 0, 0, angle);
+ return pointRotateRads(point(x - x1, y - y1), point(0, 0), angle);
case "ne":
- return rotate(x - x2, y - y1, 0, 0, angle);
+ return pointRotateRads(point(x - x2, y - y1), point(0, 0), angle);
case "sw":
- return rotate(x - x1, y - y2, 0, 0, angle);
+ return pointRotateRads(point(x - x1, y - y2), point(0, 0), angle);
case "se":
- return rotate(x - x2, y - y2, 0, 0, angle);
+ return pointRotateRads(point(x - x2, y - y2), point(0, 0), angle);
return [0, 0];
@@ -20,13 +20,14 @@ import type { AppState, Device, Zoom } from "../types";
import { getElementAbsoluteCoords } from "./bounds";
import { SIDE_RESIZING_THRESHOLD } from "../constants";
- angleToDegrees,
- pointOnLine,
- pointRotate,
-} from "../../utils/geometry/geometry";
-import type { Line, Point } from "../../utils/geometry/shape";
import { isLinearElement } from "./typeChecks";
+import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
+ pointOnLineSegment,
const isInsideTransformHandle = (
transformHandle: TransformHandle,
@@ -38,7 +39,7 @@ const isInsideTransformHandle = (
y >= transformHandle[1] &&
y <= transformHandle[1] + transformHandle[3];
-export const resizeTest = (
+export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
@@ -91,15 +92,17 @@ export const resizeTest = (
if (!(isLinearElement(element) && element.points.length <= 2)) {
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders(
- [x1 - SPACING, y1 - SPACING],
- [x2 + SPACING, y2 + SPACING],
- angleToDegrees(element.angle),
+ point(x1 - SPACING, y1 - SPACING),
+ point(x2 + SPACING, y2 + SPACING),
for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment
- if (pointOnLine([x, y], side as Line, SPACING)) {
+ if (
+ pointOnLineSegment(point(x, y), side as LineSegment<Point>, SPACING)
return dir as TransformHandleType;
@@ -137,7 +140,9 @@ export const getElementWithTransformHandleType = (
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
-export const getTransformHandleTypeFromCoords = (
+export const getTransformHandleTypeFromCoords = <
@@ -147,7 +152,7 @@ export const getTransformHandleTypeFromCoords = (
): MaybeTransformHandleType => {
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
- 0,
+ 0 as Radians,
zoom,
pointerType,
getOmitSidesForDevice(device),
@@ -173,15 +178,21 @@ export const getTransformHandleTypeFromCoords = (
- angleToDegrees(0),
- if (pointOnLine([scenePointerX, scenePointerY], side as Line, SPACING)) {
+ pointOnLineSegment(
+ side as LineSegment<Point>,
+ SPACING,
@@ -248,16 +259,16 @@ export const getCursorForResizingElement = (resizingElement: {
return cursor ? `${cursor}-resize` : "";
-const getSelectionBorders = (
+const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
[x1, y1]: Point,
[x2, y2]: Point,
center: Point,
- angleInDegrees: number,
- const topLeft = pointRotate([x1, y1], angleInDegrees, center);
- const topRight = pointRotate([x2, y1], angleInDegrees, center);
- const bottomLeft = pointRotate([x1, y2], angleInDegrees, center);
- const bottomRight = pointRotate([x2, y2], angleInDegrees, center);
+ const topLeft = pointRotateRads(point(x1, y1), center, angle);
+ const topRight = pointRotateRads(point(x2, y1), center, angle);
+ const bottomLeft = pointRotateRads(point(x1, y2), center, angle);
+ const bottomRight = pointRotateRads(point(x2, y2), center, angle);
n: [topLeft, topRight],
@@ -17,6 +17,7 @@ import type {
import { ARROW_TYPE } from "../constants";
@@ -31,8 +32,8 @@ describe("elbow arrow routing", () => {
}) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow);
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
- [-45 - arrow.x, -100.1 - arrow.y],
- [45 - arrow.x, 99.9 - arrow.y],
+ point(-45 - arrow.x, -100.1 - arrow.y),
+ point(45 - arrow.x, 99.9 - arrow.y),
]);
expect(arrow.points).toEqual([
[0, 0],
@@ -68,10 +69,7 @@ describe("elbow arrow routing", () => {
y: -100.1,
width: 90,
height: 200,
- [90, 200],
+ points: [point(0, 0), point(90, 200)],
scene.insertElement(rectangle1);
scene.insertElement(rectangle2);
@@ -83,10 +81,7 @@ describe("elbow arrow routing", () => {
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
- mutateElbowArrow(arrow, elementsMap, [
+ mutateElbowArrow(arrow, elementsMap, [point(0, 0), point(90, 200)]);
@@ -1,16 +1,19 @@
-import { cross } from "../../utils/geometry/geometry";
-import BinaryHeap from "../binaryheap";
- scaleVector,
- translatePoint,
+ pointTranslate,
+ vectorCross,
+ vectorScale,
+ type Vector,
+import BinaryHeap from "../binaryheap";
+import { aabbForElement, pointInsideBounds } from "../shapes";
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
bindPointToSnapToElementOutline,
@@ -25,6 +28,8 @@ import {
import type { Heading } from "./heading";
+ compareHeading,
+ flipHeading,
HEADING_LEFT,
HEADING_RIGHT,
@@ -41,6 +46,8 @@ import type {
import type { ElementsMap, ExcalidrawBindableElement } from "./types";
+type GridAddress = [number, number] & { _brand: "gridaddress" };
type Node = {
f: number;
g: number;
@@ -48,8 +55,8 @@ type Node = {
closed: boolean;
visited: boolean;
parent: Node | null;
- pos: Point;
- addr: [number, number];
+ pos: GlobalPoint;
+ addr: GridAddress;
type Grid = {
@@ -63,8 +70,8 @@ const BASE_PADDING = 40;
export const mutateElbowArrow = (
- offset?: Point,
+ offset?: Vector,
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
@@ -75,14 +82,20 @@ export const mutateElbowArrow = (
informMutation?: boolean;
- const origStartGlobalPoint = translatePoint(nextPoints[0], [
- arrow.x + (offset ? offset[0] : 0),
- arrow.y + (offset ? offset[1] : 0),
- const origEndGlobalPoint = translatePoint(nextPoints[nextPoints.length - 1], [
+ const origStartGlobalPoint: GlobalPoint = pointTranslate(
+ pointTranslate<LocalPoint, GlobalPoint>(
+ nextPoints[0],
+ vector(arrow.x, arrow.y),
+ offset,
+ const origEndGlobalPoint: GlobalPoint = pointTranslate(
+ nextPoints[nextPoints.length - 1],
@@ -275,7 +288,10 @@ export const mutateElbowArrow = (
if (path) {
- const points = path.map((node) => [node.pos[0], node.pos[1]]) as Point[];
+ const points = path.map((node) => [
+ node.pos[0],
+ node.pos[1],
+ ]) as GlobalPoint[];
startDongle && points.unshift(startGlobalPoint);
endDongle && points.push(endGlobalPoint);
@@ -284,7 +300,7 @@ export const mutateElbowArrow = (
...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
- angle: 0,
+ angle: 0 as Radians,
options?.informMutation,
@@ -363,7 +379,7 @@ const astar = (
// Intersect
- const neighborHalfPoint = scalePointFromOrigin(
+ const neighborHalfPoint = pointScaleFromOrigin(
neighbor.pos,
current.pos,
@@ -380,17 +396,17 @@ const astar = (
// We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet.
const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3);
const previousDirection = current.parent
- ? vectorToHeading(pointToVector(current.pos, current.parent.pos))
+ ? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos))
: startHeading;
// Do not allow going in reverse
- const reverseHeading = scaleVector(previousDirection, -1);
+ const reverseHeading = flipHeading(previousDirection);
const neighborIsReverseRoute =
- arePointsEqual(reverseHeading, neighborHeading) ||
- (arePointsEqual(start.addr, neighbor.addr) &&
- arePointsEqual(neighborHeading, startHeading)) ||
- (arePointsEqual(end.addr, neighbor.addr) &&
- arePointsEqual(neighborHeading, endHeading));
+ compareHeading(reverseHeading, neighborHeading) ||
+ (gridAddressesEqual(start.addr, neighbor.addr) &&
+ compareHeading(neighborHeading, startHeading)) ||
+ (gridAddressesEqual(end.addr, neighbor.addr) &&
+ compareHeading(neighborHeading, endHeading));
if (neighborIsReverseRoute) {
continue;
@@ -444,7 +460,7 @@ const pathTo = (start: Node, node: Node) => {
return path;
-const m_dist = (a: Point, b: Point) =>
+const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) =>
Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
@@ -541,7 +557,12 @@ const generateDynamicAABBs = (
const cX = first[2] + (second[0] - first[2]) / 2;
const cY = second[3] + (first[1] - second[3]) / 2;
- if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) {
+ vectorCross(
+ vector(a[2] - endCenterX, a[1] - endCenterY),
+ vector(a[0] - endCenterX, a[3] - endCenterY),
+ ) > 0
[first[0], first[1], cX, first[3]],
[cX, second[1], second[2], second[3]],
@@ -557,7 +578,12 @@ const generateDynamicAABBs = (
const cY = first[3] + (second[1] - first[3]) / 2;
- if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) {
+ vector(a[0] - endCenterX, a[1] - endCenterY),
+ vector(a[2] - endCenterX, a[3] - endCenterY),
[first[0], first[1], first[2], cY],
[second[0], cY, second[2], second[3]],
@@ -573,7 +599,12 @@ const generateDynamicAABBs = (
const cX = second[2] + (first[0] - second[2]) / 2;
[cX, first[1], first[2], first[3]],
[second[0], second[1], cX, second[3]],
@@ -589,7 +620,12 @@ const generateDynamicAABBs = (
@@ -615,9 +651,9 @@ const generateDynamicAABBs = (
const calculateGrid = (
aabbs: Bounds[],
- start: Point,
+ start: GlobalPoint,
startHeading: Heading,
- end: Point,
+ end: GlobalPoint,
endHeading: Heading,
common: Bounds,
): Grid => {
@@ -662,8 +698,8 @@ const calculateGrid = (
closed: false,
visited: false,
parent: null,
- addr: [col, row] as [number, number],
- pos: [x, y] as Point,
+ addr: [col, row] as GridAddress,
+ pos: [x, y] as GlobalPoint,
@@ -673,17 +709,17 @@ const calculateGrid = (
const getDonglePosition = (
bounds: Bounds,
heading: Heading,
switch (heading) {
case HEADING_UP:
- return [point[0], bounds[1]];
+ return point(p[0], bounds[1]);
case HEADING_RIGHT:
- return [bounds[2], point[1]];
+ return point(bounds[2], p[1]);
case HEADING_DOWN:
- return [point[0], bounds[3]];
+ return point(p[0], bounds[3]);
- return [bounds[0], point[1]];
+ return point(bounds[0], p[1]);
const estimateSegmentCount = (
@@ -826,7 +862,7 @@ const gridNodeFromAddr = (
* Get node for global point on canvas (if exists)
-const pointToGridNode = (point: Point, grid: Grid): Node | null => {
+const pointToGridNode = (point: GlobalPoint, grid: Grid): Node | null => {
for (let col = 0; col < grid.col; col++) {
for (let row = 0; row < grid.row; row++) {
const candidate = gridNodeFromAddr([col, row], grid);
@@ -865,15 +901,24 @@ const getBindableElementForId = (
const normalizedArrowElementUpdate = (
- global: Point[],
+ global: GlobalPoint[],
externalOffsetX?: number,
externalOffsetY?: number,
+): {
+ width: number;
+ height: number;
+} => {
const offsetX = global[0][0];
const offsetY = global[0][1];
- const points = global.map(
- (point) => [point[0] - offsetX, point[1] - offsetY] as const,
+ const points = global.map((p) =>
+ pointTranslate<GlobalPoint, LocalPoint>(
+ vectorScale(vectorFromPoint(global[0]), -1),
@@ -885,19 +930,22 @@ const normalizedArrowElementUpdate = (
/// If last and current segments have the same heading, skip the middle point
-const simplifyElbowArrowPoints = (points: Point[]): Point[] =>
+const simplifyElbowArrowPoints = (points: GlobalPoint[]): GlobalPoint[] =>
points
.slice(2)
.reduce(
- (result, point) =>
- arePointsEqual(
+ (result, p) =>
+ compareHeading(
vectorToHeading(
- pointToVector(result[result.length - 1], result[result.length - 2]),
+ result[result.length - 1],
+ result[result.length - 2],
- vectorToHeading(pointToVector(point, result[result.length - 1])),
+ vectorToHeading(vectorFromPoint(p, result[result.length - 1])),
- ? [...result.slice(0, -1), point]
- : [...result, point],
+ ? [...result.slice(0, -1), p]
+ : [...result, p],
[points[0] ?? [0, 0], points[1] ?? [1, 0]],
@@ -915,13 +963,13 @@ const neighborIndexToHeading = (idx: number): Heading => {
const getGlobalPoint = (
fixedPointRatio: [number, number] | undefined | null,
- initialPoint: Point,
- otherPoint: Point,
+ initialPoint: GlobalPoint,
+ otherPoint: GlobalPoint,
boundElement?: ExcalidrawBindableElement | null,
hoveredElement?: ExcalidrawBindableElement | null,
isDragging?: boolean,
if (isDragging) {
if (hoveredElement) {
const snapPoint = getSnapPoint(
@@ -956,36 +1004,34 @@ const getGlobalPoint = (
const getSnapPoint = (
) =>
bindPointToSnapToElementOutline(
- isRectanguloidElement(element)
- ? avoidRectangularCorner(element, point)
+ isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p,
otherPoint,
const getBindPointHeading = (
hoveredElement: ExcalidrawBindableElement | null | undefined,
getHeadingForElbowArrowSnap(
hoveredElement &&
aabbForElement(
Array(4).fill(
- distanceToBindableElement(hoveredElement, point, elementsMap),
+ distanceToBindableElement(hoveredElement, p, elementsMap),
) as [number, number, number, number],
@@ -993,8 +1039,8 @@ const getBindPointHeading = (
const getHoveredElements = (
- origStartGlobalPoint: Point,
- origEndGlobalPoint: Point,
+ origStartGlobalPoint: GlobalPoint,
+ origEndGlobalPoint: GlobalPoint,
// TODO: Might be a performance bottleneck and the Map type
@@ -1018,3 +1064,6 @@ const getHoveredElements = (
+const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
+ a[0] === b[0] && a[1] === b[1];
@@ -19,6 +19,7 @@ import type {
import { API } from "../tests/helpers/api";
import { getOriginalContainerHeightFromCache } from "./containerCache";
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -41,10 +42,7 @@ describe("textWysiwyg", () => {
type: "line",
width: 100,
height: 0,
- [100, 0],
+ points: [point(0, 0), point(100, 0)],
const textSize = 20;
const text = API.createElement({
@@ -7,7 +7,6 @@ import type {
-import { rotate } from "../math";
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
@@ -19,6 +18,8 @@ import {
isAndroid,
isIOS,
+import { point, pointRotateRads } from "../../math";
export type TransformHandleDirection =
| "n"
@@ -91,9 +92,13 @@ const generateTransformHandle = (
height: number,
cx: number,
cy: number,
): TransformHandle => {
- const [xx, yy] = rotate(x + width / 2, y + height / 2, cx, cy, angle);
+ const [xx, yy] = pointRotateRads(
+ point(x + width / 2, y + height / 2),
return [xx - width / 2, yy - height / 2, width, height];
@@ -119,7 +124,7 @@ export const getOmitSidesForDevice = (device: Device) => {
export const getTransformHandlesFromCoords = (
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
zoom: Zoom,
pointerType: PointerType,
omitSides: { [T in TransformHandleType]?: boolean } = {},
@@ -1,6 +1,5 @@
-import type { LineSegment } from "../../utils";
-import type { ElementOrToolType, Point } from "../types";
+import type { ElementOrToolType } from "../types";
import type { MarkNonNullable } from "../utility-types";
import { assertNever } from "../utils";
@@ -191,7 +190,8 @@ export const isRectangularElement = (
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
- element.type === "magicframe")
+ element.type === "magicframe" ||
+ element.type === "freedraw")
@@ -325,10 +325,6 @@ export const isFixedPointBinding = (
return binding.fixedPoint != null;
-// TODO: Move this to @excalidraw/math
-export const isPoint = (point: unknown): point is Point =>
- Array.isArray(point) && point.length === 2;
// TODO: Move this to @excalidraw/math
export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) &&
@@ -337,10 +333,3 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
-export const isLineSegment = (segment: unknown): segment is LineSegment =>
- Array.isArray(segment) &&
- segment.length === 2 &&
- isPoint(segment[0]) &&
- isPoint(segment[0]);
FONT_FAMILY,
ROUNDNESS,
@@ -49,7 +49,7 @@ type _ExcalidrawElementBase = Readonly<{
opacity: number;
width: number;
height: number;
- angle: number;
+ angle: Radians;
/** Random integer used to seed shape generation so that the roughjs shape
doesn't differ across renders. */
seed: number;
@@ -175,6 +175,15 @@ export type ExcalidrawFlowchartNodeElement =
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement;
+export type ExcalidrawRectanguloidElement =
+ | ExcalidrawRectangleElement
+ | ExcalidrawImageElement
+ | ExcalidrawTextElement
+ | ExcalidrawFreeDrawElement
+ | ExcalidrawIframeLikeElement
+ | ExcalidrawFrameLikeElement
+ | ExcalidrawEmbeddableElement;
* ExcalidrawElement should be JSON serializable and (eventually) contain
* no computed data. The list of all ExcalidrawElements should be shareable
@@ -283,8 +292,8 @@ export type Arrowhead =
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
type: "line" | "arrow";
- points: readonly Point[];
- lastCommittedPoint: Point | null;
+ points: readonly LocalPoint[];
+ lastCommittedPoint: LocalPoint | null;
startBinding: PointBinding | null;
endBinding: PointBinding | null;
startArrowhead: Arrowhead | null;
@@ -309,10 +318,10 @@ export type ExcalidrawElbowArrowElement = Merge<
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
type: "freedraw";
pressures: readonly number[];
simulatePressure: boolean;
}>;
export type FileId = string & { _brand: "FileId" };
NonDeleted,
} from "./element/types";
-import { isPointWithinBounds } from "./math";
getBoundTextElement,
getContainerElement,
@@ -30,6 +29,7 @@ import { getElementLineSegments } from "./element/bounds";
import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/";
import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
import type { ReadonlySetLike } from "./utility-types";
+import { isPointWithinBounds, point } from "../math";
// --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = (
@@ -159,9 +159,9 @@ export const isCursorInFrame = (
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap);
return isPointWithinBounds(
- [fx1, fy1],
- [cursorCoords.x, cursorCoords.y],
- [fx2, fy2],
+ point(fx1, fy1),
+ point(cursorCoords.x, cursorCoords.y),
+ point(fx2, fy2),
@@ -1,99 +0,0 @@
- isPointOnSymmetricArc,
- rangeIntersection,
- rangesOverlap,
-} from "./math";
-describe("rotate", () => {
- it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
- const x1 = 10;
- const y1 = 20;
- const x2 = 20;
- const y2 = 30;
- const angle = Math.PI / 2;
- const [rotatedX, rotatedY] = rotate(x1, y1, x2, y2, angle);
- expect([rotatedX, rotatedY]).toEqual([30, 20]);
- const res2 = rotate(rotatedX, rotatedY, x2, y2, -angle);
- expect(res2).toEqual([x1, x2]);
- });
-});
-describe("range overlap", () => {
- it("should overlap when range a contains range b", () => {
- expect(rangesOverlap([1, 4], [2, 3])).toBe(true);
- expect(rangesOverlap([1, 4], [1, 4])).toBe(true);
- expect(rangesOverlap([1, 4], [1, 3])).toBe(true);
- expect(rangesOverlap([1, 4], [2, 4])).toBe(true);
- it("should overlap when range b contains range a", () => {
- expect(rangesOverlap([2, 3], [1, 4])).toBe(true);
- expect(rangesOverlap([1, 3], [1, 4])).toBe(true);
- expect(rangesOverlap([2, 4], [1, 4])).toBe(true);
- it("should overlap when range a and b intersect", () => {
- expect(rangesOverlap([1, 4], [2, 5])).toBe(true);
-describe("range intersection", () => {
- it("should intersect completely with itself", () => {
- expect(rangeIntersection([1, 4], [1, 4])).toEqual([1, 4]);
- it("should intersect irrespective of order", () => {
- expect(rangeIntersection([1, 4], [2, 3])).toEqual([2, 3]);
- expect(rangeIntersection([2, 3], [1, 4])).toEqual([2, 3]);
- expect(rangeIntersection([1, 4], [3, 5])).toEqual([3, 4]);
- expect(rangeIntersection([3, 5], [1, 4])).toEqual([3, 4]);
- it("should intersect at the edge", () => {
- expect(rangeIntersection([1, 4], [4, 5])).toEqual([4, 4]);
- it("should not intersect", () => {
- expect(rangeIntersection([1, 4], [5, 7])).toEqual(null);
-describe("point on arc", () => {
- it("should detect point on simple arc", () => {
- expect(
- isPointOnSymmetricArc(
- {
- radius: 1,
- startAngle: -Math.PI / 4,
- endAngle: Math.PI / 4,
- [0.92291667, 0.385],
- ).toBe(true);
- it("should not detect point outside of a simple arc", () => {
- [-0.92291667, 0.385],
- ).toBe(false);
- it("should not detect point with good angle but incorrect radius", () => {
- [-0.5, 0.5],
@@ -1,715 +0,0 @@
-import type {
- NormalizedZoomValue,
- NullableGridSize,
- Zoom,
-} from "./types";
- DEFAULT_ADAPTIVE_RADIUS,
- LINE_CONFIRM_THRESHOLD,
- DEFAULT_PROPORTIONAL_RADIUS,
- ROUNDNESS,
-} from "./constants";
- ExcalidrawElement,
- ExcalidrawLinearElement,
- NonDeleted,
-} from "./element/types";
-import type { Bounds } from "./element/bounds";
-import { getCurvePathOps } from "./element/bounds";
-import type { Mutable } from "./utility-types";
-import { ShapeCache } from "./scene/ShapeCache";
-import type { Vector } from "../utils/geometry/shape";
-export const rotate = (
- // target point to rotate
- x: number,
- y: number,
- // point to rotate against
- cx: number,
- cy: number,
-): [number, number] =>
- // 𝑎′𝑥=(𝑎𝑥−𝑐𝑥)cos𝜃−(𝑎𝑦−𝑐𝑦)sin𝜃+𝑐𝑥
- // 𝑎′𝑦=(𝑎𝑥−𝑐𝑥)sin𝜃+(𝑎𝑦−𝑐𝑦)cos𝜃+𝑐𝑦.
- // https://math.stackexchange.com/questions/2204520/how-do-i-rotate-a-line-segment-in-a-specific-point-on-the-line
- (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
- (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
-export const rotatePoint = (
- center: Point,
-): [number, number] => rotate(point[0], point[1], center[0], center[1], angle);
-export const adjustXYWithRotation = (
- sides: {
- n?: boolean;
- e?: boolean;
- s?: boolean;
- w?: boolean;
- deltaX1: number,
- deltaY1: number,
- deltaX2: number,
- deltaY2: number,
-): [number, number] => {
- const cos = Math.cos(angle);
- const sin = Math.sin(angle);
- if (sides.e && sides.w) {
- x += deltaX1 + deltaX2;
- } else if (sides.e) {
- x += deltaX1 * (1 + cos);
- y += deltaX1 * sin;
- x += deltaX2 * (1 - cos);
- y += deltaX2 * -sin;
- } else if (sides.w) {
- x += deltaX1 * (1 - cos);
- y += deltaX1 * -sin;
- x += deltaX2 * (1 + cos);
- y += deltaX2 * sin;
- if (sides.n && sides.s) {
- y += deltaY1 + deltaY2;
- } else if (sides.n) {
- x += deltaY1 * sin;
- y += deltaY1 * (1 - cos);
- x += deltaY2 * -sin;
- y += deltaY2 * (1 + cos);
- } else if (sides.s) {
- x += deltaY1 * -sin;
- y += deltaY1 * (1 + cos);
- x += deltaY2 * sin;
- y += deltaY2 * (1 - cos);
- return [x, y];
-export const getPointOnAPath = (point: Point, path: Point[]) => {
- const [px, py] = point;
- const [start, ...other] = path;
- let [lastX, lastY] = start;
- let kLine: number = 0;
- let idx: number = 0;
- // if any item in the array is true, it means that a point is
- // on some segment of a line based path
- const retVal = other.some(([x2, y2], i) => {
- // we always take a line when dealing with line segments
- const x1 = lastX;
- const y1 = lastY;
- lastX = x2;
- lastY = y2;
- // if a point is not within the domain of the line segment
- // it is not on the line segment
- if (px < x1 || px > x2) {
- // check if all points lie on the same line
- // y1 = kx1 + b, y2 = kx2 + b
- // y2 - y1 = k(x2 - x2) -> k = (y2 - y1) / (x2 - x1)
- // coefficient for the line (p0, p1)
- const kL = (y2 - y1) / (x2 - x1);
- // coefficient for the line segment (p0, point)
- const kP1 = (py - y1) / (px - x1);
- // coefficient for the line segment (point, p1)
- const kP2 = (py - y2) / (px - x2);
- // because we are basing both lines from the same starting point
- // the only option for collinearity is having same coefficients
- // using it for floating point comparisons
- const epsilon = 0.3;
- // if coefficient is more than an arbitrary epsilon,
- // these lines are nor collinear
- if (Math.abs(kP1 - kL) > epsilon && Math.abs(kP2 - kL) > epsilon) {
- // store the coefficient because we are goint to need it
- kLine = kL;
- idx = i;
- return true;
- // Return a coordinate that is always on the line segment
- if (retVal === true) {
- return { x: point[0], y: kLine * point[0], segment: idx };
- return null;
-export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
- const xd = x2 - x1;
- const yd = y2 - y1;
- return Math.hypot(xd, yd);
-export const distanceSq2d = (p1: Point, p2: Point) => {
- const xd = p2[0] - p1[0];
- const yd = p2[1] - p1[1];
- return xd * xd + yd * yd;
-export const centerPoint = (a: Point, b: Point): Point => {
- return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
-// 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 = distance2d(first[0], first[1], last[0], last[1]);
- // 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;
-// Draw a line from the point to the right till infiinty
-// Check how many lines of the polygon does this infinite line intersects with
-// If the number of intersections is odd, point is in the polygon
-export const isPointInPolygon = (
- points: Point[],
- const vertices = points.length;
- // There must be at least 3 vertices in polygon
- if (vertices < 3) {
- const extreme: Point = [Number.MAX_SAFE_INTEGER, y];
- const p: Point = [x, y];
- let count = 0;
- for (let i = 0; i < vertices; i++) {
- const current = points[i];
- const next = points[(i + 1) % vertices];
- if (doSegmentsIntersect(current, next, p, extreme)) {
- if (orderedColinearOrientation(current, p, next) === 0) {
- return isPointWithinBounds(current, p, next);
- count++;
- // true if count is off
- return count % 2 === 1;
-// Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
-// This is an approximation to "does `q` lie on a segment `pr`" check.
-export const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
- return (
- q[0] <= Math.max(p[0], r[0]) &&
- q[0] >= Math.min(p[0], r[0]) &&
- q[1] <= Math.max(p[1], r[1]) &&
- q[1] >= Math.min(p[1], r[1])
-// For the ordered points p, q, r, return
-// 0 if p, q, r are colinear
-// 1 if Clockwise
-// 2 if counterclickwise
-const orderedColinearOrientation = (p: Point, q: Point, r: Point) => {
- const val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]);
- if (val === 0) {
- return 0;
- return val > 0 ? 1 : 2;
-// Check is p1q1 intersects with p2q2
-const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
- const o1 = orderedColinearOrientation(p1, q1, p2);
- const o2 = orderedColinearOrientation(p1, q1, q2);
- const o3 = orderedColinearOrientation(p2, q2, p1);
- const o4 = orderedColinearOrientation(p2, q2, q1);
- if (o1 !== o2 && o3 !== o4) {
- // p1, q1 and p2 are colinear and p2 lies on segment p1q1
- if (o1 === 0 && isPointWithinBounds(p1, p2, q1)) {
- // p1, q1 and p2 are colinear and q2 lies on segment p1q1
- if (o2 === 0 && isPointWithinBounds(p1, q2, q1)) {
- // p2, q2 and p1 are colinear and p1 lies on segment p2q2
- if (o3 === 0 && isPointWithinBounds(p2, p1, q2)) {
- // p2, q2 and q1 are colinear and q1 lies on segment p2q2
- if (o4 === 0 && isPointWithinBounds(p2, q1, q2)) {
-// TODO: Rounding this point causes some shake when free drawing
-export const getGridPoint = (
- gridSize: NullableGridSize,
- if (gridSize) {
- Math.round(x / gridSize) * gridSize,
- Math.round(y / gridSize) * gridSize,
-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 fixedRadiusSize;
-export const getControlPointsForBezierCurve = (
- element: NonDeleted<ExcalidrawLinearElement>,
- const shape = ShapeCache.generateElementShape(element, null);
- if (!shape) {
- const ops = getCurvePathOps(shape[0]);
- let currentP: Mutable<Point> = [0, 0];
- let index = 0;
- let minDistance = Infinity;
- let controlPoints: Mutable<Point>[] | null = null;
- while (index < ops.length) {
- const { op, data } = ops[index];
- if (op === "move") {
- currentP = data as unknown as Mutable<Point>;
- if (op === "bcurveTo") {
- const p0 = currentP;
- const p1 = [data[0], data[1]] as Mutable<Point>;
- const p2 = [data[2], data[3]] as Mutable<Point>;
- const p3 = [data[4], data[5]] as Mutable<Point>;
- const distance = distance2d(p3[0], p3[1], endPoint[0], endPoint[1]);
- if (distance < minDistance) {
- minDistance = distance;
- controlPoints = [p0, p1, p2, p3];
- currentP = p3;
- index++;
- return controlPoints;
-export const getBezierXY = (
- t: number,
- 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 [tx, ty];
-export const getPointsInBezierCurve = (
- const controlPoints: Mutable<Point>[] = getControlPointsForBezierCurve(
- element,
- endPoint,
- )!;
- if (!controlPoints) {
- return [];
- const pointsOnCurve: Mutable<Point>[] = [];
- let t = 1;
- // Take 20 points on curve for better accuracy
- while (t > 0) {
- const point = getBezierXY(
- pointsOnCurve.push([point[0], point[1]]);
- t -= 0.05;
- if (pointsOnCurve.length) {
- if (arePointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
- pointsOnCurve.push([endPoint[0], endPoint[1]]);
- return pointsOnCurve;
-export const getBezierCurveArcLengths = (
- const arcLengths: number[] = [];
- arcLengths[0] = 0;
- const points = getPointsInBezierCurve(element, endPoint);
- let distance = 0;
- while (index < points.length - 1) {
- const segmentDistance = distance2d(
- points[index][0],
- points[index][1],
- points[index + 1][0],
- points[index + 1][1],
- distance += segmentDistance;
- arcLengths.push(distance);
- return arcLengths;
-export const getBezierCurveLength = (
- 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 = (
- interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
- const pointsCount = arcLengths.length - 1;
- const curveLength = arcLengths.at(-1) as number;
- const targetLength = interval * curveLength;
- let low = 0;
- let high = pointsCount;
- // 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;
- 1 -
- (index +
- (targetLength - arcLengths[index]) /
- (arcLengths[index + 1] - arcLengths[index])) /
- pointsCount
-export const arePointsEqual = (p1: Point, p2: Point) => {
- return p1[0] === p2[0] && p1[1] === p2[1];
-export const isRightAngle = (angle: number) => {
- // if our angles were mathematically accurate, we could just check
- //
- // angle % (Math.PI / 2) === 0
- // but since we're in floating point land, we need to round.
- // Below, after dividing by Math.PI, a multiple of 0.5 indicates a right
- // angle, which we can check with modulo after rounding.
- return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
-export const radianToDegree = (r: number) => {
- return (r * 180) / Math.PI;
-export const degreeToRadian = (d: number) => {
- return (d / 180) * Math.PI;
-// Given two ranges, return if the two ranges overlap with each other
-// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]
-export const rangesOverlap = (
- [a0, a1]: [number, number],
- [b0, b1]: [number, number],
- if (a0 <= b0) {
- return a1 >= b0;
- if (a0 >= b0) {
- return b1 >= a0;
-// Given two ranges,return ther intersection of the two ranges if any
-// e.g. the intersection of [1, 3] and [2, 4] is [2, 3]
-export const rangeIntersection = (
- rangeA: [number, number],
- rangeB: [number, number],
-): [number, number] | null => {
- const rangeStart = Math.max(rangeA[0], rangeB[0]);
- const rangeEnd = Math.min(rangeA[1], rangeB[1]);
- if (rangeStart <= rangeEnd) {
- return [rangeStart, rangeEnd];
-export const isValueInRange = (value: number, min: number, max: number) => {
- return value >= min && value <= max;
-export const translatePoint = (p: Point, v: Vector): Point => [
- p[0] + v[0],
- p[1] + v[1],
-];
-export const scaleVector = (v: Vector, scalar: number): Vector => [
- v[0] * scalar,
- v[1] * scalar,
-export const pointToVector = (p: Point, origin: Point = [0, 0]): Vector => [
- p[0] - origin[0],
- p[1] - origin[1],
-export const scalePointFromOrigin = (
- mid: Point,
- multiplier: number,
-) => translatePoint(mid, scaleVector(pointToVector(p, mid), multiplier));
-const triangleSign = (p1: Point, p2: Point, p3: Point): number =>
- (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]);
-export const PointInTriangle = (pt: Point, v1: Point, v2: Point, v3: Point) => {
- const d1 = triangleSign(pt, v1, v2);
- const d2 = triangleSign(pt, v2, v3);
- const d3 = triangleSign(pt, v3, v1);
- const has_neg = d1 < 0 || d2 < 0 || d3 < 0;
- const has_pos = d1 > 0 || d2 > 0 || d3 > 0;
- return !(has_neg && has_pos);
-export const magnitudeSq = (vector: Vector) =>
- vector[0] * vector[0] + vector[1] * vector[1];
-export const magnitude = (vector: Vector) => Math.sqrt(magnitudeSq(vector));
-export const normalize = (vector: Vector): Vector => {
- const m = magnitude(vector);
- return [vector[0] / m, vector[1] / m];
-export const addVectors = (
- vec1: Readonly<Vector>,
- vec2: Readonly<Vector>,
-): Vector => [vec1[0] + vec2[0], vec1[1] + vec2[1]];
-export const subtractVectors = (
-): Vector => [vec1[0] - vec2[0], vec1[1] - vec2[1]];
-export const pointInsideBounds = (p: Point, 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 = [bbox.midX, bbox.midY] as Point;
- const [topLeftX, topLeftY] = rotatePoint(
- [bbox.minX, bbox.minY],
- const [topRightX, topRightY] = rotatePoint(
- [bbox.maxX, bbox.minY],
- const [bottomRightX, bottomRightY] = rotatePoint(
- [bbox.maxX, bbox.maxY],
- const [bottomLeftX, bottomLeftY] = rotatePoint(
- [bbox.minX, bbox.maxY],
- 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;
- bounds[0] - leftOffset,
- bounds[1] - topOffset,
- bounds[2] + rightOffset,
- bounds[3] + downOffset,
- return bounds;
-type PolarCoords = [number, number];
- * Return the polar coordinates for the given carthesian point represented by
- * (x, y) for the center point 0,0 where the first number returned is the radius,
- * the second is the angle in radians.
-export const carthesian2Polar = ([x, y]: Point): PolarCoords => [
- Math.hypot(x, y),
- Math.atan2(y, x),
- * Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle
- * corresponds to (1, 0) carthesian coordinates (point), i.e. to the "right".
-type SymmetricArc = { radius: number; startAngle: number; endAngle: number };
- * Determines if a carthesian point lies on a symmetric arc, i.e. an arc which
- * is part of a circle contour centered on 0, 0.
-export const isPointOnSymmetricArc = (
- { radius: arcRadius, startAngle, endAngle }: SymmetricArc,
- const [radius, angle] = carthesian2Polar(point);
- return startAngle < endAngle
- ? Math.abs(radius - arcRadius) < 0.0000001 &&
- startAngle <= angle &&
- endAngle >= angle
- : startAngle <= angle || endAngle >= angle;
-export const getCenterForBounds = (bounds: Bounds): Point => [
- bounds[0] + (bounds[2] - bounds[0]) / 2,
- bounds[1] + (bounds[3] - bounds[1]) / 2,
-export const getCenterForElement = (element: ExcalidrawElement): Point => [
-export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
- pointInsideBounds([a[0], a[1]], b) ||
- pointInsideBounds([a[2], a[1]], b) ||
- pointInsideBounds([a[2], a[3]], b) ||
- pointInsideBounds([a[0], a[3]], b) ||
- pointInsideBounds([b[0], b[1]], a) ||
- pointInsideBounds([b[2], b[1]], a) ||
- pointInsideBounds([b[2], b[3]], a) ||
- pointInsideBounds([b[0], b[3]], a);
-export const clamp = (value: number, min: number, max: number) => {
- return Math.min(Math.max(value, min), max);
-export const round = (value: number, precision: number) => {
- const multiplier = Math.pow(10, precision);
- return Math.round((value + Number.EPSILON) * multiplier) / multiplier;
@@ -1,6 +1,8 @@
-import type { Point } from "./types";
+import { pointFromPair, type GlobalPoint, type LocalPoint } from "../math";
-export const getSizeFromPoints = (points: readonly Point[]) => {
+export const getSizeFromPoints = (
+ points: readonly (GlobalPoint | LocalPoint)[],
const xs = points.map((point) => point[0]);
const ys = points.map((point) => point[1]);
@@ -10,7 +12,7 @@ export const getSizeFromPoints = (points: readonly Point[]) => {
/** @arg dimension, 0 for rescaling only x, 1 for y */
-export const rescalePoints = (
+export const rescalePoints = <Point extends GlobalPoint | LocalPoint>(
dimension: 0 | 1,
newSize: number,
points: readonly Point[],
@@ -31,7 +33,7 @@ export const rescalePoints = (
if (newCoordinate < nextMinCoordinate) {
nextMinCoordinate = newCoordinate;
- return newPoint as unknown as Point;
+ return newPoint as Point;
if (!normalize) {
@@ -45,11 +47,13 @@ export const rescalePoints = (
const translation = minCoordinate - nextMinCoordinate;
- const nextPoints = scaledPoints.map(
- (scaledPoint) =>
+ const nextPoints = scaledPoints.map((scaledPoint) =>
+ pointFromPair<Point>(
scaledPoint.map((value, currentDimension) => {
return currentDimension === dimension ? value + translation : value;
}) as [number, number],
return nextPoints;
@@ -30,7 +30,7 @@ import {
shouldShowBoundingBox,
} from "../element/transformHandles";
import { arrayToMap, throttleRAF } from "../utils";
-import type { InteractiveCanvasAppState, Point } from "../types";
+import type { InteractiveCanvasAppState } from "../types";
import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants";
import { renderSnaps } from "../renderer/renderSnaps";
@@ -69,7 +69,8 @@ import type {
InteractiveSceneRenderConfig,
RenderableElementsMap,
-import { getCornerRadius } from "../math";
+import type { GlobalPoint, LocalPoint, Radians } from "../../math";
+import { getCornerRadius } from "../shapes";
const renderLinearElementPointHighlight = (
@@ -101,7 +102,7 @@ const renderLinearElementPointHighlight = (
context.restore();
-const highlightPoint = (
+const highlightPoint = <Point extends LocalPoint | GlobalPoint>(
point: Point,
appState: InteractiveCanvasAppState,
@@ -168,7 +169,7 @@ const strokeDiamondWithRotation = (
-const renderSingleLinearPoint = (
+const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
@@ -499,7 +500,7 @@ const renderLinearPointHandles = (
- ).filter((midPoint) => midPoint !== null) as Point[];
+ ).filter((midPoint): midPoint is GlobalPoint => midPoint !== null);
midPoints.forEach((segmentMidPoint) => {
@@ -931,7 +932,7 @@ const _renderInteractiveScene = ({
context.setLineDash(initialLineDash);
appState.zoom,
"mouse",
isFrameSelected
@@ -27,7 +27,6 @@ import type {
InteractiveCanvasRenderConfig,
import { distance, getFontString, isRTL } from "../utils";
-import { getCornerRadius, isRightAngle } from "../math";
@@ -60,6 +59,8 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import { getContainingFrame } from "../frame";
import { getVerticalOffset } from "../fonts";
+import { isRightAngleRads } from "../../math";
// using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original
@@ -907,7 +908,8 @@ export const renderElement = (
(!element.angle ||
// or check if angle is a right angle in which case we can still
// disable smoothing without adversely affecting the result
- isRightAngle(element.angle))
+ // We need less-than comparison because of FP artihmetic
+ isRightAngleRads(element.angle))
// Disabling smoothing makes output much sharper, especially for
// text. Unless for non-right angles, where the aliasing is really
@@ -1,6 +1,7 @@
+import { point, type GlobalPoint, type LocalPoint } from "../../math";
import { THEME } from "../constants";
import type { PointSnapLine, PointerSnapLine } from "../snapping";
const SNAP_COLOR_LIGHT = "#ff6b6b";
const SNAP_COLOR_DARK = "#ff0000";
@@ -85,7 +86,7 @@ const drawPointerSnapLine = (
-const drawCross = (
+const drawCross = <Point extends LocalPoint | GlobalPoint>(
[x, y]: Point,
@@ -106,18 +107,18 @@ const drawCross = (
-const drawLine = (
+const drawLine = <Point extends LocalPoint | GlobalPoint>(
from: Point,
to: Point,
context.beginPath();
- context.lineTo(...from);
- context.lineTo(...to);
+ context.lineTo(from[0], from[1]);
+ context.lineTo(to[0], to[1]);
context.stroke();
-const drawGapLine = (
+const drawGapLine = <Point extends LocalPoint | GlobalPoint>(
direction: "horizontal" | "vertical",
@@ -138,24 +139,28 @@ const drawGapLine = (
const halfPoint = [(from[0] + to[0]) / 2, from[1]];
// (1)
if (!appState.zenModeEnabled) {
- drawLine([from[0], from[1] - FULL], [from[0], from[1] + FULL], context);
+ drawLine(
+ point(from[0], from[1] - FULL),
+ point(from[0], from[1] + FULL),
// (3)
drawLine(
- [halfPoint[0] - QUARTER, halfPoint[1] - HALF],
- [halfPoint[0] - QUARTER, halfPoint[1] + HALF],
+ point(halfPoint[0] - QUARTER, halfPoint[1] - HALF),
+ point(halfPoint[0] - QUARTER, halfPoint[1] + HALF),
context,
- [halfPoint[0] + QUARTER, halfPoint[1] - HALF],
- [halfPoint[0] + QUARTER, halfPoint[1] + HALF],
+ point(halfPoint[0] + QUARTER, halfPoint[1] - HALF),
+ point(halfPoint[0] + QUARTER, halfPoint[1] + HALF),
// (4)
- drawLine([to[0], to[1] - FULL], [to[0], to[1] + FULL], context);
+ drawLine(point(to[0], to[1] - FULL), point(to[0], to[1] + FULL), context);
// (2)
drawLine(from, to, context);
@@ -164,24 +169,28 @@ const drawGapLine = (
const halfPoint = [from[0], (from[1] + to[1]) / 2];
- drawLine([from[0] - FULL, from[1]], [from[0] + FULL, from[1]], context);
+ point(from[0] - FULL, from[1]),
+ point(from[0] + FULL, from[1]),
- [halfPoint[0] - HALF, halfPoint[1] - QUARTER],
- [halfPoint[0] + HALF, halfPoint[1] - QUARTER],
+ point(halfPoint[0] - HALF, halfPoint[1] - QUARTER),
+ point(halfPoint[0] + HALF, halfPoint[1] - QUARTER),
- [halfPoint[0] - HALF, halfPoint[1] + QUARTER],
- [halfPoint[0] + HALF, halfPoint[1] + QUARTER],
+ point(halfPoint[0] - HALF, halfPoint[1] + QUARTER),
+ point(halfPoint[0] + HALF, halfPoint[1] + QUARTER),
- drawLine([to[0] - FULL, to[1]], [to[0] + FULL, to[1]], context);
+ drawLine(point(to[0] - FULL, to[1]), point(to[0] + FULL, to[1]), context);
@@ -30,13 +30,13 @@ import type {
} from "../element/types";
-import { getCornerRadius, isPathALoop } from "../math";
import type { RenderableElementsMap, SVGRenderConfig } from "../scene/types";
import type { AppState, BinaryFiles } from "../types";
import { getFontFamilyString, isRTL, isTestEnv } from "../utils";
import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
+import { getCornerRadius, isPathALoop } from "../shapes";
const roughSVGDrawWithPrecision = (
rsvg: RoughSVG,
@@ -1,3 +1,4 @@
import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import { getDiamondPoints, getArrowheadPoints } from "../element";
@@ -9,7 +10,6 @@ import type {
Arrowhead,
-import { isPathALoop, getCornerRadius, distanceSq2d } from "../math";
import { generateFreeDrawShape } from "../renderer/renderElement";
import { isTransparent, assertNever } from "../utils";
import { simplify } from "points-on-curve";
@@ -23,6 +23,13 @@ import {
} from "../element/typeChecks";
import { canChangeRoundness } from "./comparisons";
import type { EmbedsValidationStatus } from "../types";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@@ -399,12 +406,14 @@ export const _generateElementShape = (
// 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 : [[0, 0]];
+ const points = element.points.length
+ ? element.points
+ : [point<LocalPoint>(0, 0)];
shape = [
generator.path(
- generateElbowArrowShape(points as [number, number][], 16),
+ generateElbowArrowShape(points, 16),
generateRoughOptions(element, true),
@@ -412,12 +421,16 @@ export const _generateElementShape = (
// curve is always the first element
// this simplifies finding the curve for an element
if (options.fill) {
- shape = [generator.polygon(points as [number, number][], options)];
+ shape = [
+ generator.polygon(points as unknown as RoughPoint[], options),
+ ];
- shape = [generator.linearPath(points as [number, number][], options)];
+ generator.linearPath(points as unknown as RoughPoint[], options),
- shape = [generator.curve(points as [number, number][], options)];
+ shape = [generator.curve(points as unknown as RoughPoint[], options)];
// add lines only in arrow
@@ -491,8 +504,8 @@ export const _generateElementShape = (
-const generateElbowArrowShape = (
- points: [number, number][],
+const generateElbowArrowShape = <Point extends GlobalPoint | LocalPoint>(
+ points: readonly Point[],
radius: number,
const subpoints = [] as [number, number][];
@@ -501,8 +514,8 @@ const generateElbowArrowShape = (
const next = points[i + 1];
const corner = Math.min(
radius,
- Math.sqrt(distanceSq2d(points[i], next)) / 2,
- Math.sqrt(distanceSq2d(points[i], prev)) / 2,
+ pointDistance(points[i], next) / 2,
+ pointDistance(points[i], prev) / 2,
if (prev[0] < points[i][0] && prev[1] === points[i][1]) {
+import { clamp, round } from "../../math";
import { MAX_ZOOM, MIN_ZOOM } from "../constants";
-import { clamp, round } from "../math";
import type { NormalizedZoomValue } from "../types";
export const getNormalizedZoom = (zoom: number): NormalizedZoomValue => {
@@ -1,5 +1,16 @@
+ isPoint,
+} from "../math";
getClosedCurveShape,
+ getCurvePathOps,
getCurveShape,
getEllipseShape,
getFreedrawShape,
@@ -18,13 +29,27 @@ import {
SelectionIcon,
TextIcon,
} from "./components/icons";
+ DEFAULT_ADAPTIVE_RADIUS,
+ DEFAULT_PROPORTIONAL_RADIUS,
+ LINE_CONFIRM_THRESHOLD,
+ ROUNDNESS,
+} from "./constants";
import { getElementAbsoluteCoords } from "./element";
+import type { Bounds } from "./element/bounds";
import { shouldTestInside } from "./element/collision";
import { LinearElementEditor } from "./element/linearElementEditor";
import { getBoundTextElement } from "./element/textElement";
-import type { ElementsMap, ExcalidrawElement } from "./element/types";
+ ElementsMap,
+ ExcalidrawElement,
+ ExcalidrawLinearElement,
+ NonDeleted,
+} from "./element/types";
import { KEYS } from "./keys";
import { ShapeCache } from "./scene/ShapeCache";
+import type { NormalizedZoomValue, Zoom } from "./types";
+import { invariant } from "./utils";
export const SHAPES = [
@@ -116,10 +141,10 @@ export const findShapeByKey = (key: string) => {
* get the pure geometric shape of an excalidraw element
* which is then used for hit detection
-export const getElementShape = (
+export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
-): GeometricShape => {
+): GeometricShape<Point> => {
case "rectangle":
case "diamond":
@@ -139,17 +164,19 @@ export const getElementShape = (
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return shouldTestInside(element)
- ? getClosedCurveShape(
+ ? getClosedCurveShape<Point>(
roughShape,
- [element.x, element.y],
+ point<Point>(element.x, element.y),
- : getCurveShape(roughShape, [element.x, element.y], element.angle, [
+ : getCurveShape<Point>(
+ roughShape,
case "ellipse":
@@ -157,15 +184,19 @@ export const getElementShape = (
case "freedraw": {
- return getFreedrawShape(element, [cx, cy], shouldTestInside(element));
+ return getFreedrawShape(
+ shouldTestInside(element),
-export const getBoundTextShape = (
+export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
-): GeometricShape | null => {
+): GeometricShape<Point> | null => {
const boundTextElement = getBoundTextElement(element, elementsMap);
@@ -189,3 +220,274 @@ export const getBoundTextShape = (
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 = point<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") {
+ isPoint(data),
+ "The returned ops is not compatible with a point",
+ currentP = pointFromPair(data);
+ if (op === "bcurveTo") {
+ const p0 = currentP;
+ const p1 = point<P>(data[0], data[1]);
+ const p2 = point<P>(data[2], data[3]);
+ const p3 = point<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 point(tx, ty);
+const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
+ 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(
+ pointsOnCurve.push(point(p[0], p[1]));
+ t -= 0.05;
+ if (pointsOnCurve.length) {
+ if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
+ pointsOnCurve.push(point(endPoint[0], endPoint[1]));
+ return pointsOnCurve;
+const getBezierCurveArcLengths = <P extends GlobalPoint | LocalPoint>(
+ const arcLengths: number[] = [];
+ arcLengths[0] = 0;
+ const points = getPointsInBezierCurve(element, endPoint);
+ let distance = 0;
+ while (index < points.length - 1) {
+ const segmentDistance = pointDistance(points[index], points[index + 1]);
+ distance += segmentDistance;
+ arcLengths.push(distance);
+ return arcLengths;
+export const getBezierCurveLength = <P extends GlobalPoint | LocalPoint>(
+ 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>(
+ interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
+ const pointsCount = arcLengths.length - 1;
+ const curveLength = arcLengths.at(-1) as number;
+ const targetLength = interval * curveLength;
+ let low = 0;
+ let high = pointsCount;
+ // 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 = point(bbox.midX, bbox.midY);
+ point(bbox.minX, bbox.minY),
+ const [topRightX, topRightY] = pointRotateRads(
+ point(bbox.maxX, bbox.minY),
+ const [bottomRightX, bottomRightY] = pointRotateRads(
+ point(bbox.maxX, bbox.maxY),
+ const [bottomLeftX, bottomLeftY] = pointRotateRads(
+ point(bbox.minX, bbox.maxY),
+ 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,
+ 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(point(a[0], a[1]), b) ||
+ pointInsideBounds(point(a[2], a[1]), b) ||
+ pointInsideBounds(point(a[2], a[3]), b) ||
+ pointInsideBounds(point(a[0], a[3]), b) ||
+ pointInsideBounds(point(b[0], b[1]), a) ||
+ pointInsideBounds(point(b[2], b[1]), a) ||
+ pointInsideBounds(point(b[2], b[3]), a) ||
+ pointInsideBounds(point(b[0], b[3]), a);
+export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
+ 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 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;
@@ -1,3 +1,12 @@
+import type { InclusiveRange } from "../math";
+ rangeInclusive,
+ rangeIntersection,
+ rangesOverlap,
import { TOOL_TYPE } from "./constants";
import type { Bounds } from "./element/bounds";
@@ -14,7 +23,6 @@ import type {
import { getMaximumGroups } from "./groups";
-import { rangeIntersection, rangesOverlap, rotatePoint } from "./math";
getSelectedElements,
getVisibleAndNonSelectedElements,
@@ -23,7 +31,7 @@ import type {
AppClassProperties,
KeyboardModifiersObject,
+ NullableGridSize,
const SNAP_DISTANCE = 8;
@@ -42,7 +50,7 @@ type Vector2D = {
-type PointPair = [Point, Point];
+type PointPair = [GlobalPoint, GlobalPoint];
export type PointSnap = {
type: "point";
@@ -62,9 +70,9 @@ export type Gap = {
// ↑ end side
startBounds: Bounds;
endBounds: Bounds;
- startSide: [Point, Point];
- endSide: [Point, Point];
- overlap: [number, number];
+ startSide: [GlobalPoint, GlobalPoint];
+ endSide: [GlobalPoint, GlobalPoint];
+ overlap: InclusiveRange;
length: number;
@@ -88,7 +96,7 @@ export type Snaps = Snap[];
export type PointSnapLine = {
type: "points";
- points: Point[];
+ points: GlobalPoint[];
export type PointerSnapLine = {
@@ -108,14 +116,14 @@ export type SnapLine = PointSnapLine | GapSnapLine | PointerSnapLine;
// -----------------------------------------------------------------------------
export class SnapCache {
- private static referenceSnapPoints: Point[] | null = null;
+ private static referenceSnapPoints: GlobalPoint[] | null = null;
private static visibleGaps: {
verticalGaps: Gap[];
horizontalGaps: Gap[];
} | null = null;
- public static setReferenceSnapPoints = (snapPoints: Point[] | null) => {
+ public static setReferenceSnapPoints = (snapPoints: GlobalPoint[] | null) => {
SnapCache.referenceSnapPoints = snapPoints;
@@ -191,8 +199,8 @@ export const getElementsCorners = (
omitCenter: false,
boundingBoxCorners: false,
- let result: Point[] = [];
+): GlobalPoint[] => {
+ let result: GlobalPoint[] = [];
if (elements.length === 1) {
const element = elements[0];
@@ -219,33 +227,53 @@ export const getElementsCorners = (
(element.type === "diamond" || element.type === "ellipse") &&
!boundingBoxCorners
- const leftMid = rotatePoint(
- [x1, y1 + halfHeight],
+ const leftMid = pointRotateRads<GlobalPoint>(
+ point(x1, y1 + halfHeight),
+ const topMid = pointRotateRads<GlobalPoint>(
+ point(x1 + halfWidth, y1),
- const topMid = rotatePoint([x1 + halfWidth, y1], [cx, cy], element.angle);
- const rightMid = rotatePoint(
- [x2, y1 + halfHeight],
+ const rightMid = pointRotateRads<GlobalPoint>(
+ point(x2, y1 + halfHeight),
- const bottomMid = rotatePoint(
- [x1 + halfWidth, y2],
+ const bottomMid = pointRotateRads<GlobalPoint>(
+ point(x1 + halfWidth, y2),
+ const center = point<GlobalPoint>(cx, cy);
result = omitCenter
? [leftMid, topMid, rightMid, bottomMid]
: [leftMid, topMid, rightMid, bottomMid, center];
- const topLeft = rotatePoint([x1, y1], [cx, cy], element.angle);
- const topRight = rotatePoint([x2, y1], [cx, cy], element.angle);
- const bottomLeft = rotatePoint([x1, y2], [cx, cy], element.angle);
- const bottomRight = rotatePoint([x2, y2], [cx, cy], element.angle);
+ const topLeft = pointRotateRads<GlobalPoint>(
+ const topRight = pointRotateRads<GlobalPoint>(
+ const bottomLeft = pointRotateRads<GlobalPoint>(
+ const bottomRight = pointRotateRads<GlobalPoint>(
? [topLeft, topRight, bottomLeft, bottomRight]
@@ -259,18 +287,18 @@ export const getElementsCorners = (
const width = maxX - minX;
const height = maxY - minY;
- const topLeft: Point = [minX, minY];
- const topRight: Point = [maxX, minY];
- const bottomLeft: Point = [minX, maxY];
- const bottomRight: Point = [maxX, maxY];
- const center: Point = [minX + width / 2, minY + height / 2];
+ const topLeft = point<GlobalPoint>(minX, minY);
+ const topRight = point<GlobalPoint>(maxX, minY);
+ const bottomLeft = point<GlobalPoint>(minX, maxY);
+ const bottomRight = point<GlobalPoint>(maxX, maxY);
+ const center = point<GlobalPoint>(minX + width / 2, minY + height / 2);
: [topLeft, topRight, bottomLeft, bottomRight, center];
- return result.map((point) => [round(point[0]), round(point[1])] as Point);
+ return result.map((p) => point(round(p[0]), round(p[1])));
const getReferenceElements = (
@@ -339,23 +367,20 @@ export const getVisibleGaps = (
startMaxX < endMinX &&
- rangesOverlap([startMinY, startMaxY], [endMinY, endMaxY])
+ rangesOverlap(
+ rangeInclusive(startMinY, startMaxY),
+ rangeInclusive(endMinY, endMaxY),
horizontalGaps.push({
startBounds,
endBounds,
- startSide: [
- [startMaxX, startMinY],
- [startMaxX, startMaxY],
- endSide: [
- [endMinX, endMinY],
- [endMinX, endMaxY],
+ startSide: [point(startMaxX, startMinY), point(startMaxX, startMaxY)],
+ endSide: [point(endMinX, endMinY), point(endMinX, endMaxY)],
length: endMinX - startMaxX,
overlap: rangeIntersection(
- [startMinY, startMaxY],
- [endMinY, endMaxY],
)!,
@@ -382,23 +407,20 @@ export const getVisibleGaps = (
startMaxY < endMinY &&
- rangesOverlap([startMinX, startMaxX], [endMinX, endMaxX])
+ rangeInclusive(startMinX, startMaxX),
+ rangeInclusive(endMinX, endMaxX),
verticalGaps.push({
- [startMinX, startMaxY],
- [endMaxX, endMinY],
+ startSide: [point(startMinX, startMaxY), point(startMaxX, startMaxY)],
+ endSide: [point(endMinX, endMinY), point(endMaxX, endMinY)],
length: endMinY - startMaxY,
- [startMinX, startMaxX],
- [endMinX, endMaxX],
@@ -441,7 +463,7 @@ const getGapSnaps = (
const centerY = (minY + maxY) / 2;
for (const gap of horizontalGaps) {
- if (!rangesOverlap([minY, maxY], gap.overlap)) {
+ if (!rangesOverlap(rangeInclusive(minY, maxY), gap.overlap)) {
@@ -510,7 +532,7 @@ const getGapSnaps = (
for (const gap of verticalGaps) {
- if (!rangesOverlap([minX, maxX], gap.overlap)) {
+ if (!rangesOverlap(rangeInclusive(minX, maxX), gap.overlap)) {
@@ -603,7 +625,7 @@ export const getReferenceSnapPoints = (
const getPointSnaps = (
selectedElements: ExcalidrawElement[],
- selectionSnapPoints: Point[],
+ selectionSnapPoints: GlobalPoint[],
app: AppClassProperties,
event: KeyboardModifiersObject,
nearestSnapsX: Snaps,
@@ -779,8 +801,8 @@ const round = (x: number) => {
return Math.round(x * 10 ** decimalPlaces) / 10 ** decimalPlaces;
-const dedupePoints = (points: Point[]): Point[] => {
- const map = new Map<string, Point>();
+const dedupePoints = (points: GlobalPoint[]): GlobalPoint[] => {
+ const map = new Map<string, GlobalPoint>();
for (const point of points) {
const key = point.join(",");
@@ -797,8 +819,8 @@ const createPointSnapLines = (
nearestSnapsY: Snaps,
): PointSnapLine[] => {
- const snapsX = {} as { [key: string]: Point[] };
- const snapsY = {} as { [key: string]: Point[] };
+ const snapsX = {} as { [key: string]: GlobalPoint[] };
+ const snapsY = {} as { [key: string]: GlobalPoint[] };
if (nearestSnapsX.length > 0) {
for (const snap of nearestSnapsX) {
@@ -809,8 +831,8 @@ const createPointSnapLines = (
snapsX[key] = [];
snapsX[key].push(
- ...snap.points.map(
- (point) => [round(point[0]), round(point[1])] as Point,
+ ...snap.points.map((p) =>
+ point<GlobalPoint>(round(p[0]), round(p[1])),
@@ -826,8 +848,8 @@ const createPointSnapLines = (
snapsY[key] = [];
snapsY[key].push(
@@ -840,8 +862,8 @@ const createPointSnapLines = (
type: "points",
points: dedupePoints(
- .map((point) => {
- return [Number(key), point[1]] as Point;
+ .map((p) => {
+ return point<GlobalPoint>(Number(key), p[1]);
})
.sort((a, b) => a[1] - b[1]),
@@ -853,8 +875,8 @@ const createPointSnapLines = (
- return [point[0], Number(key)] as Point;
+ return point<GlobalPoint>(p[0], Number(key));
.sort((a, b) => a[0] - b[0]),
@@ -898,12 +920,12 @@ const createGapSnapLines = (
const [endMinX, endMinY, endMaxX, endMaxY] = gapSnap.gap.endBounds;
const verticalIntersection = rangeIntersection(
- [minY, maxY],
+ rangeInclusive(minY, maxY),
gapSnap.gap.overlap,
const horizontalGapIntersection = rangeIntersection(
- [minX, maxX],
+ rangeInclusive(minX, maxX),
@@ -918,16 +940,16 @@ const createGapSnapLines = (
type: "gap",
direction: "horizontal",
- [gapSnap.gap.startSide[0][0], gapLineY],
- [minX, gapLineY],
+ point(gapSnap.gap.startSide[0][0], gapLineY),
+ point(minX, gapLineY),
- [maxX, gapLineY],
- [gapSnap.gap.endSide[0][0], gapLineY],
+ point(maxX, gapLineY),
+ point(gapSnap.gap.endSide[0][0], gapLineY),
@@ -944,16 +966,16 @@ const createGapSnapLines = (
direction: "vertical",
- [gapLineX, gapSnap.gap.startSide[0][1]],
- [gapLineX, minY],
+ point(gapLineX, gapSnap.gap.startSide[0][1]),
+ point(gapLineX, minY),
- [gapLineX, maxY],
- [gapLineX, gapSnap.gap.endSide[0][1]],
+ point(gapLineX, maxY),
+ point(gapLineX, gapSnap.gap.endSide[0][1]),
@@ -969,18 +991,12 @@ const createGapSnapLines = (
- [startMaxX, gapLineY],
- [endMinX, gapLineY],
+ points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)],
- [endMaxX, gapLineY],
+ points: [point(endMaxX, gapLineY), point(minX, gapLineY)],
@@ -995,18 +1011,12 @@ const createGapSnapLines = (
- [startMinX, gapLineY],
+ points: [point(maxX, gapLineY), point(startMinX, gapLineY)],
@@ -1021,18 +1031,12 @@ const createGapSnapLines = (
- [gapLineX, startMinY],
+ points: [point(gapLineX, maxY), point(gapLineX, startMinY)],
- [gapLineX, startMaxY],
- [gapLineX, endMinY],
+ points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)],
@@ -1047,18 +1051,12 @@ const createGapSnapLines = (
- [gapLineX, endMaxY],
+ points: [point(gapLineX, endMaxY), point(gapLineX, minY)],
@@ -1071,8 +1069,8 @@ const createGapSnapLines = (
gapSnapLines.map((gapSnapLine) => {
...gapSnapLine,
- points: gapSnapLine.points.map(
+ points: gapSnapLine.points.map((p) =>
+ point(round(p[0]), round(p[1])),
) as PointPair,
@@ -1117,40 +1115,40 @@ export const snapResizingElements = (
- const selectionSnapPoints: Point[] = [];
+ const selectionSnapPoints: GlobalPoint[] = [];
if (transformHandle) {
switch (transformHandle) {
case "e": {
- selectionSnapPoints.push([maxX, minY], [maxX, maxY]);
+ selectionSnapPoints.push(point(maxX, minY), point(maxX, maxY));
case "w": {
- selectionSnapPoints.push([minX, minY], [minX, maxY]);
+ selectionSnapPoints.push(point(minX, minY), point(minX, maxY));
case "n": {
- selectionSnapPoints.push([minX, minY], [maxX, minY]);
+ selectionSnapPoints.push(point(minX, minY), point(maxX, minY));
case "s": {
- selectionSnapPoints.push([minX, maxY], [maxX, maxY]);
+ selectionSnapPoints.push(point(minX, maxY), point(maxX, maxY));
case "ne": {
- selectionSnapPoints.push([maxX, minY]);
+ selectionSnapPoints.push(point(maxX, minY));
case "nw": {
- selectionSnapPoints.push([minX, minY]);
+ selectionSnapPoints.push(point(minX, minY));
case "se": {
- selectionSnapPoints.push([maxX, maxY]);
+ selectionSnapPoints.push(point(maxX, maxY));
case "sw": {
- selectionSnapPoints.push([minX, maxY]);
+ selectionSnapPoints.push(point(minX, maxY));
@@ -1192,11 +1190,11 @@ export const snapResizingElements = (
round(bound),
- const corners: Point[] = [
- [x1, y2],
- [x2, y1],
- [x2, y2],
+ const corners: GlobalPoint[] = [
getPointSnaps(
@@ -1232,8 +1230,8 @@ export const snapNewElement = (
- const selectionSnapPoints: Point[] = [
- [origin.x + dragOffset.x, origin.y + dragOffset.y],
+ const selectionSnapPoints: GlobalPoint[] = [
+ point(origin.x + dragOffset.x, origin.y + dragOffset.y),
const snapDistance = getSnapDistance(app.state.zoom.value);
@@ -1333,7 +1331,7 @@ export const getSnapLinesAtPointer = (
verticalSnapLines.push({
type: "pointer",
- points: [corner, [corner[0], pointer.y]],
+ points: [corner, point(corner[0], pointer.y)],
@@ -1349,7 +1347,7 @@ export const getSnapLinesAtPointer = (
horizontalSnapLines.push({
- points: [corner, [pointer.x, corner[1]]],
+ points: [corner, point(pointer.x, corner[1])],
@@ -1386,3 +1384,18 @@ export const isActiveToolNonLinearSnappable = (
activeToolType === TOOL_TYPE.text
+// TODO: Rounding this point causes some shake when free drawing
+export const getGridPoint = (
+ gridSize: NullableGridSize,
+ if (gridSize) {
+ Math.round(x / gridSize) * gridSize,
+ Math.round(y / gridSize) * gridSize,
@@ -7,6 +7,7 @@ import { API } from "./helpers/api";
@@ -31,12 +32,7 @@ describe("element binding", () => {
y: 0,
height: 1,
+ points: [point(0, 0), point(0, 0), point(100, 0), point(100, 0)],
API.setElements([rect, arrow]);
expect(arrow.startBinding).toBe(null);
@@ -314,10 +310,7 @@ describe("element binding", () => {
const arrow1 = API.createElement({
id: "arrow1",
- [0, -87.45777932247563],
+ points: [point(0, 0), point(0, -87.45777932247563)],
startBinding: {
elementId: "rectangle1",
focus: 0.2,
@@ -335,10 +328,7 @@ describe("element binding", () => {
const arrow2 = API.createElement({
id: "arrow2",
elementId: "text1",
import { DEFAULT_FONT_FAMILY } from "../../constants";
@@ -7,7 +8,7 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
y: 237,
width: 214,
height: 214,
strokeColor: "#000000",
backgroundColor: "#15aabf",
fillStyle: "hachure",
@@ -28,6 +28,8 @@ import { KEYS } from "../keys";
import { getBoundTextElementPosition } from "../element/textElement";
import { createPasteEvent } from "../clipboard";
import { arrayToMap, cloneJSON } from "../utils";
+import { point, type Radians } from "../../math";
@@ -131,7 +133,7 @@ const createLinearElementWithCurveInsideMinMaxPoints = (
y: -2412.5069664197654,
width: 1750.4888916015625,
height: 410.51605224609375,
backgroundColor: "#fa5252",
@@ -145,9 +147,9 @@ const createLinearElementWithCurveInsideMinMaxPoints = (
link: null,
- [-922.4761962890625, 300.3277587890625],
- [828.0126953125, 410.51605224609375],
+ point<LocalPoint>(-922.4761962890625, 300.3277587890625),
+ point<LocalPoint>(828.0126953125, 410.51605224609375),
@@ -423,8 +425,8 @@ describe("arrow", () => {
it("flips a rotated arrow horizontally with line inside min/max points bounds", async () => {
- const originalAngle = Math.PI / 4;
- const expectedAngle = (7 * Math.PI) / 4;
+ const originalAngle = (Math.PI / 4) as Radians;
+ const expectedAngle = ((7 * Math.PI) / 4) as Radians;
const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
API.setElements([line]);
API.setAppState({
@@ -444,8 +446,8 @@ describe("arrow", () => {
it("flips a rotated arrow vertically with line inside min/max points bounds", async () => {
@@ -477,8 +479,8 @@ describe("arrow", () => {
//TODO: elements with curve outside minMax points have a wrong bounding box!!!
it.skip("flips a rotated arrow horizontally with line outside min/max points bounds", async () => {
const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
API.updateElement(line, { angle: originalAngle });
@@ -501,8 +503,8 @@ describe("arrow", () => {
it.skip("flips a rotated arrow vertically with line outside min/max points bounds", async () => {
@@ -585,8 +587,8 @@ describe("line", () => {
//TODO: elements with curve outside minMax points have a wrong bounding box
it.skip("flips a rotated line horizontally with line outside min/max points bounds", async () => {
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
@@ -600,8 +602,8 @@ describe("line", () => {
it.skip("flips a rotated line vertically with line outside min/max points bounds", async () => {
@@ -619,8 +621,8 @@ describe("line", () => {
it("flips a rotated line horizontally with line inside min/max points bounds", async () => {
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
@@ -640,8 +642,8 @@ describe("line", () => {
it("flips a rotated line vertically with line inside min/max points bounds", async () => {
@@ -772,8 +774,8 @@ describe("image", () => {
it("flips an rotated image horizontally correctly", async () => {
//paste image
await createImage();
await waitFor(() => {
@@ -790,8 +792,8 @@ describe("image", () => {
it("flips an rotated image vertically correctly", async () => {
@@ -27,7 +27,7 @@ import {
newImageElement,
newMagicFrameElement,
} from "../../element/newElement";
import { getSelectedElements } from "../../scene/selection";
import { isLinearElementType } from "../../element/typeChecks";
import type { Mutable } from "../../utility-types";
@@ -36,6 +36,7 @@ import type App from "../../components/App";
import { createTestHook } from "../../components/App";
import type { Action } from "../../actions/types";
import { mutateElement } from "../../element/mutateElement";
+import { point, type LocalPoint, type Radians } from "../../../math";
const readFile = util.promisify(fs.readFile);
// so that window.h is available when App.tsx is not imported as well.
@@ -171,7 +172,7 @@ export class API {
containerId?: T extends "text"
? ExcalidrawTextElement["containerId"]
: never;
- points?: T extends "arrow" | "line" ? readonly Point[] : never;
+ points?: T extends "arrow" | "line" ? readonly LocalPoint[] : never;
locked?: boolean;
fileId?: T extends "image" ? string : never;
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
@@ -218,7 +219,7 @@ export class API {
frameId: rest.frameId ?? null,
index: rest.index ?? null,
- angle: rest.angle ?? 0,
+ angle: (rest.angle ?? 0) as Radians,
strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
backgroundColor:
rest.backgroundColor ?? appState.currentItemBackgroundColor,
@@ -293,8 +294,8 @@ export class API {
type,
points: rest.points ?? [
- [100, 100],
+ point<LocalPoint>(100, 100),
elbowed: rest.elbowed ?? false,
@@ -306,8 +307,8 @@ export class API {
-import type { Point, ToolType } from "../../types";
+import type { ToolType } from "../../types";
@@ -30,10 +30,11 @@ import {
isFrameLikeElement,
} from "../../element/typeChecks";
import { getCommonBounds, getElementPointsCoords } from "../../element/bounds";
-import { rotatePoint } from "../../math";
import { getTextEditor } from "../queries/dom";
import { arrayToMap } from "../../utils";
+import type { GlobalPoint, LocalPoint, Radians } from "../../../math";
createTestHook();
@@ -131,27 +132,29 @@ export class Keyboard {
-const getElementPointForSelection = (element: ExcalidrawElement): Point => {
+const getElementPointForSelection = (
+ element: ExcalidrawElement,
- const target: Point = [
+ const target = point<GlobalPoint>(
x +
(isLinearElement(element) || isFreeDrawElement(element) ? 0 : width / 2),
- let center: Point;
+ let center: GlobalPoint;
if (isLinearElement(element)) {
const bounds = getElementPointsCoords(element, element.points);
- center = [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2];
+ center = point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2);
- center = [x + width / 2, y + height / 2];
+ center = point(x + width / 2, y + height / 2);
if (isTextElement(element)) {
return center;
- return rotatePoint(target, center, angle);
+ return pointRotateRads(target, center, angle);
export class Pointer {
@@ -328,7 +331,7 @@ const transform = (
const isFrameSelected = elements.some(isFrameLikeElement);
h.state.zoom,
isFrameSelected ? OMIT_SIDES_FOR_FRAME : OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
@@ -450,7 +453,7 @@ export class UI {
width?: number;
height?: number;
angle?: number;
- points?: T extends "line" | "arrow" | "freedraw" ? Point[] : never;
+ points?: T extends "line" | "arrow" | "freedraw" ? LocalPoint[] : never;
} = {},
): Element<T> & {
/** Returns the actual, current element from the elements array, instead
@@ -459,9 +462,9 @@ export class UI {
} {
const width = initialWidth ?? initialHeight ?? size;
const height = initialHeight ?? size;
- const points: Point[] = initialPoints ?? [
+ const points: LocalPoint[] = initialPoints ?? [
UI.clickTool(type);
@@ -44,6 +44,8 @@ import { queryByText } from "@testing-library/react";
import { HistoryEntry } from "../history";
import { AppStateChange, ElementsChange } from "../change";
import { Snapshot, StoreAction } from "../store";
@@ -2038,9 +2040,9 @@ describe("history", () => {
width: 178.9000000000001,
height: 236.10000000000002,
- [178.9000000000001, 0],
- [178.9000000000001, 236.10000000000002],
+ point(178.9000000000001, 0),
+ point(178.9000000000001, 236.10000000000002),
elementId: "KPrBI4g_v9qUB1XxYLgSz",
@@ -2156,12 +2158,12 @@ describe("history", () => {
elements: [
newElementWith(h.elements[0] as ExcalidrawLinearElement, {
- [5, 5],
- [10, 10],
- [15, 15],
- [20, 20],
+ point(5, 5),
+ point(10, 10),
+ point(15, 15),
+ point(20, 20),
+ ] as LocalPoint[],
storeAction: StoreAction.UPDATE,
@@ -4003,7 +4005,7 @@ describe("history", () => {
newElementWith(h.elements[0], {
x: 200,
y: 200,
- angle: 90,
+ angle: 90 as Radians,
storeAction: StoreAction.CAPTURE,
@@ -4121,7 +4123,7 @@ describe("history", () => {
x: 205,
y: 205,
@@ -8,7 +8,6 @@ import type {
import { Excalidraw, mutateElement } from "../index";
-import { centerPoint } from "../math";
import { reseed } from "../random";
import * as StaticScene from "../renderer/staticScene";
import * as InteractiveCanvas from "../renderer/interactiveScene";
@@ -16,7 +15,6 @@ import * as InteractiveCanvas from "../renderer/interactiveScene";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { screen, render, fireEvent, GlobalTestState } from "./test-utils";
import { act, queryByTestId, queryByText } from "@testing-library/react";
@@ -29,6 +27,8 @@ import * as textElementUtils from "../element/textElement";
import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
import { vi } from "vitest";
+import { pointCenter, point } from "../../math";
const renderInteractiveScene = vi.spyOn(
InteractiveCanvas,
@@ -57,9 +57,9 @@ describe("Test Linear Elements", () => {
interactiveCanvas = container.querySelector("canvas.interactive")!;
- const p1: Point = [20, 20];
- const p2: Point = [60, 20];
- const midpoint = centerPoint(p1, p2);
+ const p1 = point<GlobalPoint>(20, 20);
+ const p2 = point<GlobalPoint>(60, 20);
+ const midpoint = pointCenter<GlobalPoint>(p1, p2);
const delta = 50;
@@ -75,10 +75,7 @@ describe("Test Linear Elements", () => {
roughness,
- [p2[0] - p1[0], p2[1] - p1[1]],
+ points: [point(0, 0), point(p2[0] - p1[0], p2[1] - p1[1])],
roundness,
@@ -102,9 +99,9 @@ describe("Test Linear Elements", () => {
- [p3[0], p3[1]],
+ point(p3[0], p3[1]),
+ point(p2[0] - p1[0], p2[1] - p1[1]),
@@ -129,7 +126,7 @@ describe("Test Linear Elements", () => {
expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
- const drag = (startPoint: Point, endPoint: Point) => {
+ const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
fireEvent.pointerDown(interactiveCanvas, {
clientX: startPoint[0],
clientY: startPoint[1],
@@ -144,7 +141,7 @@ describe("Test Linear Elements", () => {
- const deletePoint = (point: Point) => {
+ const deletePoint = (point: GlobalPoint) => {
clientX: point[0],
clientY: point[1],
@@ -164,7 +161,7 @@ describe("Test Linear Elements", () => {
expect(line.points.length).toEqual(2);
mouse.clickAt(midpoint[0], midpoint[1]);
- drag(midpoint, [midpoint[0] + 1, midpoint[1] + 1]);
+ drag(midpoint, point(midpoint[0] + 1, midpoint[1] + 1));
@@ -172,7 +169,7 @@ describe("Test Linear Elements", () => {
expect(line.y).toBe(originalY);
- drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
+ drag(midpoint, point(midpoint[0] + delta, midpoint[1] + delta));
expect(line.x).toBe(originalX);
expect(line.points.length).toEqual(3);
@@ -187,7 +184,7 @@ describe("Test Linear Elements", () => {
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2);
// drag line from midpoint
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -251,7 +248,7 @@ describe("Test Linear Elements", () => {
@@ -264,7 +261,7 @@ describe("Test Linear Elements", () => {
enterLineEditingMode(line);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
@@ -356,10 +353,13 @@ describe("Test Linear Elements", () => {
h.state,
- const startPoint = centerPoint(points[0], midPoints[0] as Point);
+ const startPoint = pointCenter(points[0], midPoints[0]!);
const deltaX = 50;
const deltaY = 20;
- const endPoint: Point = [startPoint[0] + deltaX, startPoint[1] + deltaY];
+ const endPoint = point<GlobalPoint>(
+ startPoint[0] + deltaX,
+ startPoint[1] + deltaY,
// Move the element
drag(startPoint, endPoint);
@@ -399,8 +399,8 @@ describe("Test Linear Elements", () => {
// This is the expected midpoint for line with round edge
// hence hardcoding it so if later some bug is introduced
// this will fail and we can fix it
- const firstSegmentMidpoint: Point = [55, 45];
- const lastSegmentMidpoint: Point = [75, 40];
+ const firstSegmentMidpoint = point<GlobalPoint>(55, 45);
+ const lastSegmentMidpoint = point<GlobalPoint>(75, 40);
let line: ExcalidrawLinearElement;
@@ -414,17 +414,20 @@ describe("Test Linear Elements", () => {
it("should allow dragging lines from midpoints in between segments", async () => {
// drag line via first segment midpoint
- drag(firstSegmentMidpoint, [
- firstSegmentMidpoint[0] + delta,
- firstSegmentMidpoint[1] + delta,
+ drag(
+ firstSegmentMidpoint,
+ firstSegmentMidpoint[0] + delta,
+ firstSegmentMidpoint[1] + delta,
expect(line.points.length).toEqual(4);
// drag line from last segment midpoint
- drag(lastSegmentMidpoint, [
- lastSegmentMidpoint[0] + delta,
- lastSegmentMidpoint[1] + delta,
+ lastSegmentMidpoint,
+ point(lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta),
`16`,
@@ -472,10 +475,10 @@ describe("Test Linear Elements", () => {
- const hitCoords: Point = [points[0][0], points[0][1]];
+ const hitCoords = point<GlobalPoint>(points[0][0], points[0][1]);
// Drag from first point
- drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]);
+ drag(hitCoords, point(hitCoords[0] - delta, hitCoords[1] - delta));
@@ -513,10 +516,10 @@ describe("Test Linear Elements", () => {
- drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
+ drag(hitCoords, point(hitCoords[0] + delta, hitCoords[1] + delta));
@@ -551,10 +554,10 @@ describe("Test Linear Elements", () => {
// dragging line from last segment midpoint
- lastSegmentMidpoint[0] + 50,
- lastSegmentMidpoint[1] + 50,
+ point(lastSegmentMidpoint[0] + 50, lastSegmentMidpoint[1] + 50),
const midPoints = LinearElementEditor.getEditorMidPoints(
@@ -586,12 +589,14 @@ describe("Test Linear Elements", () => {
- const firstSegmentMidpoint: Point = [
- 55.9697848965255, 47.442326230998205,
- const lastSegmentMidpoint: Point = [
- 76.08587175006699, 43.294165939653226,
+ const firstSegmentMidpoint = point<GlobalPoint>(
+ 55.9697848965255,
+ 47.442326230998205,
+ const lastSegmentMidpoint = point<GlobalPoint>(
+ 76.08587175006699,
+ 43.294165939653226,
beforeEach(() => {
@@ -605,17 +610,20 @@ describe("Test Linear Elements", () => {
// drag line from first segment midpoint
@@ -661,10 +669,10 @@ describe("Test Linear Elements", () => {
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line,
@@ -709,10 +717,10 @@ describe("Test Linear Elements", () => {
@@ -741,10 +749,10 @@ describe("Test Linear Elements", () => {
it("should update all the midpoints when a point is deleted", async () => {
const elementsMap = arrayToMap(h.elements);
@@ -803,8 +811,11 @@ describe("Test Linear Elements", () => {
API.setSelectedElements([line]);
enterLineEditingMode(line, true);
drag(
- [line.points[0][0] + line.x, line.points[0][1] + line.y],
- [dragEndPositionOffset[0] + line.x, dragEndPositionOffset[1] + line.y],
+ point(line.points[0][0] + line.x, line.points[0][1] + line.y),
+ dragEndPositionOffset[0] + line.x,
+ dragEndPositionOffset[1] + line.y,
expect(line.points).toMatchInlineSnapshot(`
@@ -916,14 +927,18 @@ describe("Test Linear Elements", () => {
const position = LinearElementEditor.getBoundTextElementPosition(
@@ -1136,7 +1151,7 @@ describe("Test Linear Elements", () => {
// Drag from last point
- drag(points[1], [points[1][0] + 300, points[1][1]]);
+ drag(points[1], point(points[1][0] + 300, points[1][1]));
expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(`
@@ -1335,14 +1350,14 @@ describe("Test Linear Elements", () => {
index: 0,
- point: [line.points[0][0] + 10, line.points[0][1] + 10],
+ point: point(line.points[0][0] + 10, line.points[0][1] + 10),
index: line.points.length - 1,
- point: [
line.points[line.points.length - 1][0] - 10,
line.points[line.points.length - 1][1] - 10,
new Map() as SceneElementsMap,
ExcalidrawFreeDrawElement,
import type { Bounds } from "../element/bounds";
import { getElementPointsCoords } from "../element/bounds";
import { Excalidraw } from "../index";
@@ -16,6 +15,8 @@ import { KEYS } from "../keys";
import { isLinearElement } from "../element/typeChecks";
@@ -217,18 +218,13 @@ describe("generic element", () => {
describe.each(["line", "freedraw"] as const)("%s element", (type) => {
- const points: Record<typeof type, Point[]> = {
- line: [
- [60, -20],
- [20, 40],
- [-40, 0],
+ const points: Record<typeof type, LocalPoint[]> = {
+ line: [point(0, 0), point(60, -20), point(20, 40), point(-40, 0)],
freedraw: [
- [-2.474600807561444, 41.021700699972],
- [3.6627956000014024, 47.84174560617245],
- [40.495224145598115, 47.15909710753482],
+ point(-2.474600807561444, 41.021700699972),
+ point(3.6627956000014024, 47.84174560617245),
+ point(40.495224145598115, 47.15909710753482),
@@ -296,11 +292,11 @@ describe("arrow element", () => {
it("resizes with a label", async () => {
const arrow = UI.createElement("arrow", {
- [40, 140],
- [80, 60], // label's anchor
- [180, 20],
- [200, 120],
+ point(40, 140),
+ point(80, 60), // label's anchor
+ point(180, 20),
+ point(200, 120),
const label = await UI.editText(arrow, "Hello");
@@ -694,24 +690,24 @@ describe("multiple selection", () => {
x: 60,
y: 40,
- [-40, 40],
- [-60, 0],
- [0, -40],
- [40, 20],
- [0, 40],
+ point(-40, 40),
+ point(-60, 0),
+ point(0, -40),
+ point(40, 20),
+ point(0, 40),
const freedraw = UI.createElement("freedraw", {
x: 63.56072661326618,
y: 100,
- [-43.56072661326618, 18.15048126846341],
- [-43.56072661326618, 29.041198460587566],
- [-38.115368017204105, 42.652452795512204],
- [-19.964886748740696, 66.24829266003775],
- [19.056612930986716, 77.1390098521619],
+ point(-43.56072661326618, 18.15048126846341),
+ point(-43.56072661326618, 29.041198460587566),
+ point(-38.115368017204105, 42.652452795512204),
+ point(-19.964886748740696, 66.24829266003775),
+ point(19.056612930986716, 77.1390098521619),
@@ -1050,13 +1046,13 @@ describe("multiple selection", () => {
- [-20, 60],
- [40, 40],
- [-20, 100],
- [-60, 60],
+ point(-20, 60),
+ point(40, 40),
+ point(-20, 100),
+ point(-60, 60),
@@ -24,7 +24,6 @@ import type {
ExcalidrawNonSelectionElement,
import type { Action } from "./actions/types";
-import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type { LinearElementEditor } from "./element/linearElementEditor";
import type { SuggestedBinding } from "./element/binding";
import type { ImportedDataState } from "./data/types";
@@ -43,8 +42,6 @@ import type { SnapLine } from "./snapping";
import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types";
import type { StoreActionType } from "./store";
-export type Point = Readonly<RoughPoint>;
export type SocketId = string & { _brand: "SocketId" };
export type Collaborator = Readonly<{
+import { average } from "../math";
import { COLOR_PALETTE } from "./colors";
import type { EVENT } from "./constants";
@@ -992,10 +993,6 @@ export const isMemberOf = <T extends string>(
export const cloneJSON = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));
-export const isFiniteNumber = (value: any): value is number => {
- return typeof value === "number" && Number.isFinite(value);
export const updateStable = <T extends any[] | Record<string, any>>(
prevValue: T,
nextValue: T,
@@ -1079,7 +1076,6 @@ export function addEventListener(
-const average = (a: number, b: number) => (a + b) / 2;
export function getSvgPathFromStroke(points: number[][], closed = true) {
const len = points.length;
@@ -1,7 +1,7 @@
+import { isLineSegment, lineSegment, point, type GlobalPoint } from "../math";
import type { LineSegment } from "../utils";
import type { BoundingBox, Bounds } from "./element/bounds";
-import { isBounds, isLineSegment } from "./element/typeChecks";
+import { isBounds } from "./element/typeChecks";
// The global data holder to collect the debug operations
declare global {
@@ -15,18 +15,22 @@ declare global {
export type DebugElement = {
color: string;
- data: LineSegment;
+ data: LineSegment<GlobalPoint>;
permanent: boolean;
export const debugDrawLine = (
- segment: LineSegment | LineSegment[],
+ segment: LineSegment<GlobalPoint> | LineSegment<GlobalPoint>[],
opts?: {
color?: string;
permanent?: boolean;
- (isLineSegment(segment) ? [segment] : segment).forEach((data) =>
+ const segments = (
+ isLineSegment(segment) ? [segment] : segment
+ ) as LineSegment<GlobalPoint>[];
+ segments.forEach((data) =>
addToCurrentFrame({
color: opts?.color ?? "red",
data,
@@ -36,7 +40,7 @@ export const debugDrawLine = (
export const debugDrawPoint = (
@@ -47,20 +51,20 @@ export const debugDrawPoint = (
const yOffset = opts?.fuzzy ? Math.random() * 3 : 0;
debugDrawLine(
- [point[0] + xOffset - 10, point[1] + yOffset - 10],
- [point[0] + xOffset + 10, point[1] + yOffset + 10],
+ point<GlobalPoint>(p[0] + xOffset - 10, p[1] + yOffset - 10),
+ point<GlobalPoint>(p[0] + xOffset + 10, p[1] + yOffset + 10),
color: opts?.color ?? "cyan",
permanent: opts?.permanent,
- [point[0] + xOffset - 10, point[1] + yOffset + 10],
- [point[0] + xOffset + 10, point[1] + yOffset - 10],
+ point<GlobalPoint>(p[0] + xOffset - 10, p[1] + yOffset + 10),
+ point<GlobalPoint>(p[0] + xOffset + 10, p[1] + yOffset - 10),
@@ -78,22 +82,22 @@ export const debugDrawBoundingBox = (
(Array.isArray(box) ? box : [box]).forEach((bbox) =>
+ point<GlobalPoint>(bbox.minX, bbox.minY),
+ point<GlobalPoint>(bbox.maxX, bbox.minY),
+ point<GlobalPoint>(bbox.maxX, bbox.maxY),
+ point<GlobalPoint>(bbox.minX, bbox.maxY),
@@ -113,22 +117,22 @@ export const debugDrawBounds = (
(isBounds(box) ? [box] : box).forEach((bbox) =>
- [bbox[0], bbox[1]],
- [bbox[2], bbox[1]],
- [bbox[2], bbox[3]],
- [bbox[0], bbox[3]],
+ point<GlobalPoint>(bbox[0], bbox[1]),
+ point<GlobalPoint>(bbox[2], bbox[1]),
+ point<GlobalPoint>(bbox[2], bbox[3]),
+ point<GlobalPoint>(bbox[0], bbox[3]),
color: opts?.color ?? "green",
@@ -0,0 +1,21 @@
+# @excalidraw/math
+## Install
+```bash
+npm install @excalidraw/math
+```
+If you prefer Yarn over npm, use this command to install the Excalidraw utils package:
+yarn add @excalidraw/math
+With PNPM, similarly install the package with this command:
+pnpm add @excalidraw/math
+## API
@@ -0,0 +1,47 @@
+ PolarCoords,
+} from "./types";
+import { PRECISION } from "./utils";
+// TODO: Simplify with modulo and fix for angles beyond 4*Math.PI and - 4*Math.PI
+export const normalizeRadians = (angle: Radians): Radians => {
+ if (angle < 0) {
+ return (angle + 2 * Math.PI) as Radians;
+ if (angle >= 2 * Math.PI) {
+ return (angle - 2 * Math.PI) as Radians;
+ return angle;
+ * Return the polar coordinates for the given cartesian point represented by
+ * (x, y) for the center point 0,0 where the first number returned is the radius,
+ * the second is the angle in radians.
+export const cartesian2Polar = <P extends GlobalPoint | LocalPoint>([
+]: P): PolarCoords => [Math.hypot(x, y), Math.atan2(y, x)];
+export function degreesToRadians(degrees: Degrees): Radians {
+ return ((degrees * Math.PI) / 180) as Radians;
+}
+export function radiansToDegrees(degrees: Radians): Degrees {
+ return ((degrees * 180) / Math.PI) as Degrees;
+ * Determines if the provided angle is a right angle.
+ *
+ * @param rads The angle to measure
+ * @returns TRUE if the provided angle is a right angle
+export function isRightAngleRads(rads: Radians): boolean {
+ return Math.abs(Math.sin(2 * rads)) < PRECISION;
@@ -0,0 +1,41 @@
+import { isPointOnSymmetricArc } from "./arc";
+import { point } from "./point";
+describe("point on arc", () => {
+ it("should detect point on simple arc", () => {
+ expect(
+ isPointOnSymmetricArc(
+ {
+ radius: 1,
+ startAngle: -Math.PI / 4,
+ endAngle: Math.PI / 4,
+ point(0.92291667, 0.385),
+ ).toBe(true);
+ });
+ it("should not detect point outside of a simple arc", () => {
+ point(-0.92291667, 0.385),
+ ).toBe(false);
+ it("should not detect point with good angle but incorrect radius", () => {
+ point(-0.5, 0.5),
+});
@@ -0,0 +1,20 @@
+import { cartesian2Polar } from "./angle";
+import type { GlobalPoint, LocalPoint, SymmetricArc } from "./types";
+ * Determines if a cartesian point lies on a symmetric arc, i.e. an arc which
+ * is part of a circle contour centered on 0, 0.
+export const isPointOnSymmetricArc = <P extends GlobalPoint | LocalPoint>(
+ { radius: arcRadius, startAngle, endAngle }: SymmetricArc,
+ point: P,
+ const [radius, angle] = cartesian2Polar(point);
+ return startAngle < endAngle
+ ? Math.abs(radius - arcRadius) < PRECISION &&
+ startAngle <= angle &&
+ endAngle >= angle
+ : startAngle <= angle || endAngle >= angle;
@@ -0,0 +1,223 @@
+import { point, pointRotateRads } from "./point";
+import type { Curve, GlobalPoint, LocalPoint, Radians } from "./types";
+ * @param a
+ * @param b
+ * @param c
+ * @param d
+ * @returns
+export function curve<Point extends GlobalPoint | LocalPoint>(
+ c: Point,
+ d: Point,
+) {
+ return [a, b, c, d] as Curve<Point>;
+export const curveRotate = <Point extends LocalPoint | GlobalPoint>(
+ curve: Curve<Point>,
+ origin: Point,
+ return curve.map((p) => pointRotateRads(p, origin, angle));
+ * @param pointsIn
+ * @param curveTightness
+export function curveToBezier<Point extends LocalPoint | GlobalPoint>(
+ pointsIn: readonly Point[],
+ curveTightness = 0,
+): Point[] {
+ const len = pointsIn.length;
+ if (len < 3) {
+ throw new Error("A curve must have at least three points.");
+ const out: Point[] = [];
+ if (len === 3) {
+ out.push(
+ point(pointsIn[0][0], pointsIn[0][1]), // Points need to be cloned
+ point(pointsIn[1][0], pointsIn[1][1]), // Points need to be cloned
+ point(pointsIn[2][0], pointsIn[2][1]), // Points need to be cloned
+ const points: Point[] = [];
+ points.push(pointsIn[0], pointsIn[0]);
+ for (let i = 1; i < pointsIn.length; i++) {
+ points.push(pointsIn[i]);
+ if (i === pointsIn.length - 1) {
+ const b: Point[] = [];
+ const s = 1 - curveTightness;
+ out.push(point(points[0][0], points[0][1]));
+ for (let i = 1; i + 2 < points.length; i++) {
+ const cachedVertArray = points[i];
+ b[0] = point(cachedVertArray[0], cachedVertArray[1]);
+ b[1] = point(
+ cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6,
+ cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6,
+ b[2] = point(
+ points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6,
+ points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6,
+ b[3] = point(points[i + 1][0], points[i + 1][1]);
+ out.push(b[1], b[2], b[3]);
+ return out;
+ * @param t
+ * @param controlPoints
+export const cubicBezierPoint = <Point extends LocalPoint | GlobalPoint>(
+ controlPoints: Curve<Point>,
+): Point => {
+ const [p0, p1, p2, p3] = controlPoints;
+ const x =
+ Math.pow(1 - t, 3) * p0[0] +
+ 3 * Math.pow(1 - t, 2) * t * p1[0] +
+ 3 * (1 - t) * Math.pow(t, 2) * p2[0] +
+ Math.pow(t, 3) * p3[0];
+ const y =
+ Math.pow(1 - t, 3) * p0[1] +
+ 3 * Math.pow(1 - t, 2) * t * p1[1] +
+ 3 * (1 - t) * Math.pow(t, 2) * p2[1] +
+ Math.pow(t, 3) * p3[1];
+ return point(x, y);
+ * @param point
+export const cubicBezierDistance = <Point extends LocalPoint | GlobalPoint>(
+ point: Point,
+ // Calculate the closest point on the Bezier curve to the given point
+ const t = findClosestParameter(point, controlPoints);
+ // Calculate the coordinates of the closest point on the curve
+ const [closestX, closestY] = cubicBezierPoint(t, controlPoints);
+ // Calculate the distance between the given point and the closest point on the curve
+ const distance = Math.sqrt(
+ (point[0] - closestX) ** 2 + (point[1] - closestY) ** 2,
+ return distance;
+const solveCubic = (a: number, b: number, c: number, d: number) => {
+ // This function solves the cubic equation ax^3 + bx^2 + cx + d = 0
+ const roots: number[] = [];
+ const discriminant =
+ 18 * a * b * c * d -
+ 4 * Math.pow(b, 3) * d +
+ Math.pow(b, 2) * Math.pow(c, 2) -
+ 4 * a * Math.pow(c, 3) -
+ 27 * Math.pow(a, 2) * Math.pow(d, 2);
+ if (discriminant >= 0) {
+ const C = Math.cbrt((discriminant + Math.sqrt(discriminant)) / 2);
+ const D = Math.cbrt((discriminant - Math.sqrt(discriminant)) / 2);
+ const root1 = (-b - C - D) / (3 * a);
+ const root2 = (-b + (C + D) / 2) / (3 * a);
+ const root3 = (-b + (C + D) / 2) / (3 * a);
+ roots.push(root1, root2, root3);
+ const realPart = -b / (3 * a);
+ const root1 =
+ 2 * Math.sqrt(-b / (3 * a)) * Math.cos(Math.acos(realPart) / 3);
+ const root2 =
+ 2 *
+ Math.sqrt(-b / (3 * a)) *
+ Math.cos((Math.acos(realPart) + 2 * Math.PI) / 3);
+ const root3 =
+ Math.cos((Math.acos(realPart) + 4 * Math.PI) / 3);
+ return roots;
+const findClosestParameter = <Point extends LocalPoint | GlobalPoint>(
+ // This function finds the parameter t that minimizes the distance between the point
+ // and any point on the cubic Bezier curve.
+ // Use the direct formula to find the parameter t
+ const a = p3[0] - 3 * p2[0] + 3 * p1[0] - p0[0];
+ const b = 3 * p2[0] - 6 * p1[0] + 3 * p0[0];
+ const c = 3 * p1[0] - 3 * p0[0];
+ const d = p0[0] - point[0];
+ const rootsX = solveCubic(a, b, c, d);
+ // Do the same for the y-coordinate
+ const e = p3[1] - 3 * p2[1] + 3 * p1[1] - p0[1];
+ const f = 3 * p2[1] - 6 * p1[1] + 3 * p0[1];
+ const g = 3 * p1[1] - 3 * p0[1];
+ const h = p0[1] - point[1];
+ const rootsY = solveCubic(e, f, g, h);
+ // Select the real root that is between 0 and 1 (inclusive)
+ const validRootsX = rootsX.filter((root) => root >= 0 && root <= 1);
+ const validRootsY = rootsY.filter((root) => root >= 0 && root <= 1);
+ if (validRootsX.length === 0 || validRootsY.length === 0) {
+ // No valid roots found, use the midpoint as a fallback
+ return 0.5;
+ // Choose the parameter t that minimizes the distance
+ let closestT = 0;
+ for (const rootX of validRootsX) {
+ for (const rootY of validRootsY) {
+ (rootX - point[0]) ** 2 + (rootY - point[1]) ** 2,
+ closestT = (rootX + rootY) / 2; // Use the average for a smoother result
+ return closestT;
-import { point, toString, direction, offset } from "../ga";
+import * as GA from "./ga";
+import { point, toString, direction, offset } from "./ga";
+import * as GAPoint from "./gapoints";
+import * as GALine from "./galines";
+import * as GATransform from "./gatransforms";
describe("geometric algebra", () => {
describe("points", () => {
@@ -0,0 +1,12 @@
+export * from "./arc";
+export * from "./angle";
+export * from "./curve";
+export * from "./line";
+export * from "./point";
+export * from "./polygon";
+export * from "./range";
+export * from "./segment";
+export * from "./triangle";
+export * from "./types";
+export * from "./vector";
+export * from "./utils";
@@ -0,0 +1,52 @@
+import { pointCenter, pointRotateRads } from "./point";
+import type { GlobalPoint, Line, LocalPoint, Radians } from "./types";
+ * Create a line from two points.
+ * @param points The two points lying on the line
+ * @returns The line on which the points lie
+export function line<P extends GlobalPoint | LocalPoint>(a: P, b: P): Line<P> {
+ return [a, b] as Line<P>;
+ * Convenient point creation from an array of two points.
+ * @param param0 The array with the two points to convert to a line
+ * @returns The created line
+export function lineFromPointPair<P extends GlobalPoint | LocalPoint>([a, b]: [
+ P,
+]): Line<P> {
+ return line(a, b);
+ * TODO
+ * @param pointArray
+export function lineFromPointArray<P extends GlobalPoint | LocalPoint>(
+ pointArray: P[],
+): Line<P> | undefined {
+ return pointArray.length === 2
+ ? line<P>(pointArray[0], pointArray[1])
+ : undefined;
+// return the coordinates resulting from rotating the given line about an origin by an angle in degrees
+// note that when the origin is not given, the midpoint of the given line is used as the origin
+export const lineRotate = <Point extends LocalPoint | GlobalPoint>(
+ l: Line<Point>,
+ origin?: Point,
+): Line<Point> => {
+ return line(
+ pointRotateRads(l[0], origin || pointCenter(l[0], l[1]), angle),
+ pointRotateRads(l[1], origin || pointCenter(l[0], l[1]), angle),
@@ -0,0 +1,61 @@
+{
+ "name": "@excalidraw/math",
+ "version": "0.1.0",
+ "main": "./dist/prod/index.js",
+ "type": "module",
+ "module": "./dist/prod/index.js",
+ "exports": {
+ ".": {
+ "development": "./dist/dev/index.js",
+ "default": "./dist/prod/index.js"
+ "types": "./dist/utils/index.d.ts",
+ "files": [
+ "dist/*"
+ "description": "Excalidraw math functions",
+ "publishConfig": {
+ "access": "public"
+ "license": "MIT",
+ "keywords": [
+ "excalidraw",
+ "excalidraw-math",
+ "math",
+ "vector",
+ "algebra",
+ "2d"
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not ie <= 11",
+ "not op_mini all",
+ "not safari < 12",
+ "not kaios <= 2.5",
+ "not edge < 79",
+ "not chrome < 70",
+ "not and_uc < 13",
+ "not samsung < 10"
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ "bugs": "https://github.com/excalidraw/excalidraw/issues",
+ "repository": "https://github.com/excalidraw/excalidraw",
+ "dependencies": {
+ "@excalidraw/utils": "*"
+ "scripts": {
+ "gen:types": "rm -rf types && tsc",
+ "build:umd": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js",
+ "build:esm": "rm -rf dist && node ../../scripts/buildUtils.js && yarn gen:types",
+ "build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js",
+ "pack": "yarn build:umd && yarn pack"
@@ -0,0 +1,24 @@
+import type { Radians } from "./types";
+describe("rotate", () => {
+ it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
+ const x1 = 10;
+ const y1 = 20;
+ const x2 = 20;
+ const y2 = 30;
+ const angle = (Math.PI / 2) as Radians;
+ expect([rotatedX, rotatedY]).toEqual([30, 20]);
+ const res2 = pointRotateRads(
+ point(rotatedX, rotatedY),
+ expect(res2).toEqual([x1, x2]);
@@ -0,0 +1,257 @@
+import { degreesToRadians } from "./angle";
+import { vectorFromPoint, vectorScale } from "./vector";
+ * Create a properly typed Point instance from the X and Y coordinates.
+ * @param x The X coordinate
+ * @param y The Y coordinate
+ * @returns The branded and created point
+export function point<Point extends GlobalPoint | LocalPoint>(
+): Point {
+ return [x, y] as Point;
+ * Converts and remaps an array containing a pair of numbers to Point.
+ * @param numberArray The number array to check and to convert to Point
+ * @returns The point instance
+export function pointFromArray<Point extends GlobalPoint | LocalPoint>(
+ numberArray: number[],
+): Point | undefined {
+ return numberArray.length === 2
+ ? point<Point>(numberArray[0], numberArray[1])
+ * Converts and remaps a pair of numbers to Point.
+ * @param pair A number pair to convert to Point
+export function pointFromPair<Point extends GlobalPoint | LocalPoint>(
+ pair: [number, number],
+ return pair as Point;
+ * Convert a vector to a point.
+ * @param v The vector to convert
+ * @returns The point the vector points at with origin 0,0
+export function pointFromVector<P extends GlobalPoint | LocalPoint>(
+ v: Vector,
+): P {
+ return v as unknown as P;
+ * Checks if the provided value has the shape of a Point.
+ * @param p The value to attempt verification on
+ * @returns TRUE if the provided value has the shape of a local or global point
+export function isPoint(p: unknown): p is LocalPoint | GlobalPoint {
+ Array.isArray(p) &&
+ p.length === 2 &&
+ typeof p[0] === "number" &&
+ !isNaN(p[0]) &&
+ typeof p[1] === "number" &&
+ !isNaN(p[1])
+ * Compare two points coordinate-by-coordinate and if
+ * they are closer than INVERSE_PRECISION it returns TRUE.
+ * @param a Point The first point to compare
+ * @param b Point The second point to compare
+ * @returns TRUE if the points are sufficiently close to each other
+export function pointsEqual<Point extends GlobalPoint | LocalPoint>(
+): boolean {
+ const abs = Math.abs;
+ return abs(a[0] - b[0]) < PRECISION && abs(a[1] - b[1]) < PRECISION;
+ * Roate a point by [angle] radians.
+ * @param point The point to rotate
+ * @param center The point to rotate around, the center point
+ * @param angle The radians to rotate the point by
+ * @returns The rotated point
+export function pointRotateRads<Point extends GlobalPoint | LocalPoint>(
+ [x, y]: Point,
+ [cx, cy]: Point,
+ (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
+ (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
+ * Roate a point by [angle] degree.
+ * @param angle The degree to rotate the point by
+export function pointRotateDegs<Point extends GlobalPoint | LocalPoint>(
+ center: Point,
+ angle: Degrees,
+ return pointRotateRads(point, center, degreesToRadians(angle));
+ * Translate a point by a vector.
+ * WARNING: This is not for translating Excalidraw element points!
+ * You need to account for rotation on base coordinates
+ * on your own.
+ * CONSIDER USING AN APPROPRIATE ELEMENT-AWARE TRANSLATE!
+ * @param p The point to apply the translation on
+ * @param v The vector to translate by
+// TODO 99% of use is translating between global and local coords, which need to be formalized
+export function pointTranslate<
+ From extends GlobalPoint | LocalPoint,
+ To extends GlobalPoint | LocalPoint,
+>(p: From, v: Vector = [0, 0] as Vector): To {
+ return point(p[0] + v[0], p[1] + v[1]);
+ * Find the center point at equal distance from both points.
+ * @param a One of the points to create the middle point for
+ * @param b The other point to create the middle point for
+ * @returns The middle point
+export function pointCenter<P extends LocalPoint | GlobalPoint>(a: P, b: P): P {
+ return point((a[0] + b[0]) / 2, (a[1] + b[1]) / 2);
+ * Add together two points by their coordinates like you'd apply a translation
+ * to a point by a vector.
+ * @param a One point to act as a basis
+ * @param b The other point to act like the vector to translate by
+export function pointAdd<Point extends LocalPoint | GlobalPoint>(
+ return point(a[0] + b[0], a[1] + b[1]);
+ * Subtract a point from another point like you'd translate a point by an
+ * invese vector.
+ * @param a The point to translate
+ * @param b The point which will act like a vector
+ * @returns The resulting point
+export function pointSubtract<Point extends LocalPoint | GlobalPoint>(
+ return point(a[0] - b[0], a[1] - b[1]);
+ * Calculate the distance between two points.
+ * @param a First point
+ * @param b Second point
+ * @returns The euclidean distance between the two points.
+export function pointDistance<P extends LocalPoint | GlobalPoint>(
+ a: P,
+ b: P,
+): number {
+ return Math.hypot(b[0] - a[0], b[1] - a[1]);
+ * Calculate the squared distance between two points.
+ * Note: Use this if you only compare distances, it saves a square root.
+export function pointDistanceSq<P extends LocalPoint | GlobalPoint>(
+ * Scale a point from a given origin by the multiplier.
+ * @param p The point to scale
+ * @param mid The origin to scale from
+ * @param multiplier The scaling factor
+export const pointScaleFromOrigin = <P extends GlobalPoint | LocalPoint>(
+ mid: P,
+ multiplier: number,
+) => pointTranslate(mid, vectorScale(vectorFromPoint(p, mid), multiplier));
+ * Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
+ * This is an approximation to "does `q` lie on a segment `pr`" check.
+ * @param p The first point to compare against
+ * @param q The actual point this function checks whether is in between
+ * @param r The other point to compare against
+ * @returns TRUE if q is indeed between p and r
+export const isPointWithinBounds = <P extends GlobalPoint | LocalPoint>(
+ q: P,
+ r: P,
+ q[0] <= Math.max(p[0], r[0]) &&
+ q[0] >= Math.min(p[0], r[0]) &&
+ q[1] <= Math.max(p[1], r[1]) &&
+ q[1] >= Math.min(p[1], r[1])
@@ -0,0 +1,72 @@
+import { pointsEqual } from "./point";
+import { lineSegment, pointOnLineSegment } from "./segment";
+import type { GlobalPoint, LocalPoint, Polygon } from "./types";
+export function polygon<Point extends GlobalPoint | LocalPoint>(
+ ...points: Point[]
+ return polygonClose(points) as Polygon<Point>;
+export function polygonFromPoints<Point extends GlobalPoint | LocalPoint>(
+ points: Point[],
+export const polygonIncludesPoint = <Point extends LocalPoint | GlobalPoint>(
+ polygon: Polygon<Point>,
+ const x = point[0];
+ const y = point[1];
+ let inside = false;
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+ const xi = polygon[i][0];
+ const yi = polygon[i][1];
+ const xj = polygon[j][0];
+ const yj = polygon[j][1];
+ ((yi > y && yj <= y) || (yi <= y && yj > y)) &&
+ x < ((xj - xi) * (y - yi)) / (yj - yi) + xi
+ inside = !inside;
+ return inside;
+export const pointOnPolygon = <Point extends LocalPoint | GlobalPoint>(
+ p: Point,
+ poly: Polygon<Point>,
+ threshold = PRECISION,
+ let on = false;
+ for (let i = 0, l = poly.length - 1; i < l; i++) {
+ if (pointOnLineSegment(p, lineSegment(poly[i], poly[i + 1]), threshold)) {
+ on = true;
+ break;
+ return on;
+function polygonClose<Point extends LocalPoint | GlobalPoint>(
+ polygon: Point[],
+ return polygonIsClosed(polygon)
+ ? polygon
+ : ([...polygon, polygon[0]] as Polygon<Point>);
+function polygonIsClosed<Point extends LocalPoint | GlobalPoint>(
+ return pointsEqual(polygon[0], polygon[polygon.length - 1]);
@@ -0,0 +1,51 @@
+import { rangeInclusive, rangeIntersection, rangesOverlap } from "./range";
+describe("range overlap", () => {
+ const range1_4 = rangeInclusive(1, 4);
+ it("should overlap when range a contains range b", () => {
+ expect(rangesOverlap(range1_4, rangeInclusive(2, 3))).toBe(true);
+ expect(rangesOverlap(range1_4, range1_4)).toBe(true);
+ expect(rangesOverlap(range1_4, rangeInclusive(1, 3))).toBe(true);
+ expect(rangesOverlap(range1_4, rangeInclusive(2, 4))).toBe(true);
+ it("should overlap when range b contains range a", () => {
+ expect(rangesOverlap(rangeInclusive(2, 3), range1_4)).toBe(true);
+ expect(rangesOverlap(rangeInclusive(1, 3), range1_4)).toBe(true);
+ expect(rangesOverlap(rangeInclusive(2, 4), range1_4)).toBe(true);
+ it("should overlap when range a and b intersect", () => {
+ expect(rangesOverlap(range1_4, rangeInclusive(2, 5))).toBe(true);
+describe("range intersection", () => {
+ it("should intersect completely with itself", () => {
+ expect(rangeIntersection(range1_4, range1_4)).toEqual(range1_4);
+ it("should intersect irrespective of order", () => {
+ expect(rangeIntersection(range1_4, rangeInclusive(2, 3))).toEqual([2, 3]);
+ expect(rangeIntersection(rangeInclusive(2, 3), range1_4)).toEqual([2, 3]);
+ expect(rangeIntersection(range1_4, rangeInclusive(3, 5))).toEqual(
+ rangeInclusive(3, 4),
+ expect(rangeIntersection(rangeInclusive(3, 5), range1_4)).toEqual(
+ it("should intersect at the edge", () => {
+ expect(rangeIntersection(range1_4, rangeInclusive(4, 5))).toEqual(
+ rangeInclusive(4, 4),
+ it("should not intersect", () => {
+ expect(rangeIntersection(range1_4, rangeInclusive(5, 7))).toEqual(null);
@@ -0,0 +1,82 @@
+import { toBrandedType } from "../excalidraw/utils";
+import type { InclusiveRange } from "./types";
+ * Create an inclusive range from the two numbers provided.
+ * @param start Start of the range
+ * @param end End of the range
+export function rangeInclusive(start: number, end: number): InclusiveRange {
+ return toBrandedType<InclusiveRange>([start, end]);
+ * Turn a number pair into an inclusive range.
+ * @param pair The number pair to convert to an inclusive range
+ * @returns The new inclusive range
+export function rangeInclusiveFromPair(pair: [start: number, end: number]) {
+ return toBrandedType<InclusiveRange>(pair);
+ * Given two ranges, return if the two ranges overlap with each other e.g.
+ * [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5].
+ * @param param0 One of the ranges to compare
+ * @param param1 The other range to compare against
+ * @returns TRUE if the ranges overlap
+export const rangesOverlap = (
+ [a0, a1]: InclusiveRange,
+ [b0, b1]: InclusiveRange,
+ if (a0 <= b0) {
+ return a1 >= b0;
+ if (a0 >= b0) {
+ return b1 >= a0;
+ * Given two ranges,return ther intersection of the two ranges if any e.g. the
+ * intersection of [1, 3] and [2, 4] is [2, 3].
+ * @param param0 The first range to compare
+ * @param param1 The second range to compare
+ * @returns The inclusive range intersection or NULL if no intersection
+export const rangeIntersection = (
+): InclusiveRange | null => {
+ const rangeStart = Math.max(a0, b0);
+ const rangeEnd = Math.min(a1, b1);
+ if (rangeStart <= rangeEnd) {
+ return toBrandedType<InclusiveRange>([rangeStart, rangeEnd]);
+ * Determine if a value is inside a range.
+ * @param value The value to check
+ * @param range The range
+export const rangeIncludesValue = (
+ value: number,
+ [min, max]: InclusiveRange,
+ return value >= min && value <= max;
@@ -0,0 +1,158 @@
+ pointFromVector,
+} from "./point";
+import type { GlobalPoint, LineSegment, LocalPoint, Radians } from "./types";
+ vectorAdd,
+ vectorSubtract,
+} from "./vector";
+ * Create a line segment from two points.
+ * @param points The two points delimiting the line segment on each end
+ * @returns The line segment delineated by the points
+export function lineSegment<P extends GlobalPoint | LocalPoint>(
+): LineSegment<P> {
+ return [a, b] as LineSegment<P>;
+export function lineSegmentFromPointArray<P extends GlobalPoint | LocalPoint>(
+): LineSegment<P> | undefined {
+ ? lineSegment<P>(pointArray[0], pointArray[1])
+ * @param segment
+export const isLineSegment = <Point extends GlobalPoint | LocalPoint>(
+ segment: unknown,
+): segment is LineSegment<Point> =>
+ Array.isArray(segment) &&
+ segment.length === 2 &&
+ isPoint(segment[0]) &&
+ isPoint(segment[0]);
+ * Return the coordinates resulting from rotating the given line about an origin by an angle in radians
+ * note that when the origin is not given, the midpoint of the given line is used as the origin.
+ * @param l
+ * @param angle
+ * @param origin
+export const lineSegmentRotate = <Point extends LocalPoint | GlobalPoint>(
+ l: LineSegment<Point>,
+): LineSegment<Point> => {
+ return lineSegment(
+ * Calculates the point two line segments with a definite start and end point
+ * intersect at.
+export const segmentsIntersectAt = <Point extends GlobalPoint | LocalPoint>(
+ a: Readonly<LineSegment<Point>>,
+ b: Readonly<LineSegment<Point>>,
+): Point | null => {
+ const a0 = vectorFromPoint(a[0]);
+ const a1 = vectorFromPoint(a[1]);
+ const b0 = vectorFromPoint(b[0]);
+ const b1 = vectorFromPoint(b[1]);
+ const r = vectorSubtract(a1, a0);
+ const s = vectorSubtract(b1, b0);
+ const denominator = vectorCross(r, s);
+ if (denominator === 0) {
+ const i = vectorSubtract(vectorFromPoint(b[0]), vectorFromPoint(a[0]));
+ const u = vectorCross(i, r) / denominator;
+ const t = vectorCross(i, s) / denominator;
+ if (u === 0) {
+ const p = vectorAdd(a0, vectorScale(r, t));
+ if (t >= 0 && t < 1 && u >= 0 && u < 1) {
+ return pointFromVector<Point>(p);
+export const pointOnLineSegment = <Point extends LocalPoint | GlobalPoint>(
+ line: LineSegment<Point>,
+ const distance = distanceToLineSegment(point, line);
+ if (distance === 0) {
+ return true;
+ return distance < threshold;
+export const distanceToLineSegment = <Point extends LocalPoint | GlobalPoint>(
+ const [x, y] = point;
+ const [[x1, y1], [x2, y2]] = line;
+ const A = x - x1;
+ const B = y - y1;
+ const C = x2 - x1;
+ const D = y2 - y1;
+ const dot = A * C + B * D;
+ const len_sq = C * C + D * D;
+ let param = -1;
+ if (len_sq !== 0) {
+ param = dot / len_sq;
+ let xx;
+ let yy;
+ if (param < 0) {
+ xx = x1;
+ yy = y1;
+ } else if (param > 1) {
+ xx = x2;
+ yy = y2;
+ xx = x1 + param * C;
+ yy = y1 + param * D;
+ const dx = x - xx;
+ const dy = y - yy;
+ return Math.sqrt(dx * dx + dy * dy);
@@ -0,0 +1,28 @@
+import type { GlobalPoint, LocalPoint, Triangle } from "./types";
+// Types
+ * Tests if a point lies inside a triangle. This function
+ * will return FALSE if the point lies exactly on the sides
+ * of the triangle.
+ * @param triangle The triangle to test the point for
+ * @param p The point to test whether is in the triangle
+ * @returns TRUE if the point is inside of the triangle
+export function triangleIncludesPoint<P extends GlobalPoint | LocalPoint>(
+ [a, b, c]: Triangle<P>,
+ const triangleSign = (p1: P, p2: P, p3: P) =>
+ (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]);
+ const d1 = triangleSign(p, a, b);
+ const d2 = triangleSign(p, b, c);
+ const d3 = triangleSign(p, c, a);
+ const has_neg = d1 < 0 || d2 < 0 || d3 < 0;
+ const has_pos = d1 > 0 || d2 > 0 || d3 > 0;
+ return !(has_neg && has_pos);
@@ -0,0 +1,130 @@
+//
+// Measurements
+ * By definition one radian is the angle subtended at the centre
+ * of a circle by an arc that is equal in length to the radius.
+export type Radians = number & { _brand: "excalimath__radian" };
+ * An angle measurement of a plane angle in which one full
+ * rotation is 360 degrees.
+export type Degrees = number & { _brand: "excalimath_degree" };
+// Range
+ * A number range which includes the start and end numbers in the range.
+export type InclusiveRange = [number, number] & { _brand: "excalimath_degree" };
+// Point
+ * Represents a 2D position in world or canvas space. A
+ * global coordinate.
+export type GlobalPoint = [x: number, y: number] & {
+ _brand: "excalimath__globalpoint";
+ * Represents a 2D position in whatever local space it's
+ * needed. A local coordinate.
+export type LocalPoint = [x: number, y: number] & {
+ _brand: "excalimath__localpoint";
+// Line
+ * A line is an infinitely long object with no width, depth, or curvature.
+export type Line<P extends GlobalPoint | LocalPoint> = [p: P, q: P] & {
+ _brand: "excalimath_line";
+ * In geometry, a line segment is a part of a straight
+ * line that is bounded by two distinct end points, and
+ * contains every point on the line that is between its endpoints.
+export type LineSegment<P extends GlobalPoint | LocalPoint> = [a: P, b: P] & {
+ _brand: "excalimath_linesegment";
+// Vector
+ * Represents a 2D vector
+export type Vector = [u: number, v: number] & {
+ _brand: "excalimath__vector";
+// Triangles
+ * A triangle represented by 3 points
+export type Triangle<P extends GlobalPoint | LocalPoint> = [
+ c: P,
+] & {
+ _brand: "excalimath__triangle";
+// Polygon
+ * A polygon is a closed shape by connecting the given points
+ * rectangles and diamonds are modelled by polygons
+export type Polygon<Point extends GlobalPoint | LocalPoint> = Point[] & {
+ _brand: "excalimath_polygon";
+// Curve
+ * Cubic bezier curve with four control points
+export type Curve<Point extends GlobalPoint | LocalPoint> = [
+ Point,
+ _brand: "excalimath_curve";
+export type PolarCoords = [
+ radius: number,
+ /** angle in radians */
+];
+ * Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle
+ * corresponds to (1, 0) cartesian coordinates (point), i.e. to the "right".
+export type SymmetricArc = {
+ radius: number;
+ startAngle: number;
+ endAngle: number;
@@ -0,0 +1,17 @@
+export const PRECISION = 10e-5;
+export function clamp(value: number, min: number, max: number) {
+ return Math.min(Math.max(value, min), max);
+export function round(value: number, precision: number) {
+ const multiplier = Math.pow(10, precision);
+ return Math.round((value + Number.EPSILON) * multiplier) / multiplier;
+export const average = (a: number, b: number) => (a + b) / 2;
+export const isFiniteNumber = (value: any): value is number => {
+ return typeof value === "number" && Number.isFinite(value);
+import { isVector } from ".";
+describe("Vector", () => {
+ test("isVector", () => {
+ expect(isVector([5, 5])).toBe(true);
+ expect(isVector([-5, -5])).toBe(true);
+ expect(isVector([5, 0.5])).toBe(true);
+ expect(isVector(null)).toBe(false);
+ expect(isVector(undefined)).toBe(false);
+ expect(isVector([5, NaN])).toBe(false);
@@ -0,0 +1,141 @@
+import type { GlobalPoint, LocalPoint, Vector } from "./types";
+ * Create a vector from the x and y coordiante elements.
+ * @param x The X aspect of the vector
+ * @param y T Y aspect of the vector
+ * @returns The constructed vector with X and Y as the coordinates
+export function vector(
+ originX: number = 0,
+ originY: number = 0,
+): Vector {
+ return [x - originX, y - originY] as Vector;
+ * Turn a point into a vector with the origin point.
+ * @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
+export function vectorFromPoint<Point extends GlobalPoint | LocalPoint>(
+ origin: Point = [0, 0] as Point,
+ return vector(p[0] - origin[0], p[1] - origin[1]);
+ * Cross product is a binary operation on two vectors in 2D space.
+ * It results in a vector that is perpendicular to both vectors.
+ * @param a One of the vectors to use for the directed area calculation
+ * @param b The other vector to use for the directed area calculation
+ * @returns The directed area value for the two vectos
+export function vectorCross(a: Vector, b: Vector): number {
+ return a[0] * b[1] - b[0] * a[1];
+ * Dot product is defined as the sum of the products of the
+ * two vectors.
+ * @param a One of the vectors for which the sum of products is calculated
+ * @param b The other vector for which the sum of products is calculated
+ * @returns The sum of products of the two vectors
+export function vectorDot(a: Vector, b: Vector) {
+ return a[0] * b[0] + a[1] * b[1];
+ * Determines if the value has the shape of a Vector.
+ * @param v The value to test
+ * @returns TRUE if the value has the shape and components of a Vectors
+export function isVector(v: unknown): v is Vector {
+ Array.isArray(v) &&
+ v.length === 2 &&
+ typeof v[0] === "number" &&
+ !isNaN(v[0]) &&
+ typeof v[1] === "number" &&
+ !isNaN(v[1])
+ * Add two vectors by adding their coordinates.
+ * @param a One of the vectors to add
+ * @param b The other vector to add
+ * @returns The sum vector of the two provided vectors
+export function vectorAdd(a: Readonly<Vector>, b: Readonly<Vector>): Vector {
+ return [a[0] + b[0], a[1] + b[1]] as Vector;
+ * @param start One of the vectors to add
+ * @param end The other vector to add
+export function vectorSubtract(
+ start: Readonly<Vector>,
+ end: Readonly<Vector>,
+ return [start[0] - end[0], start[1] - end[1]] as Vector;
+ * Scale vector by a scalar.
+ * @param v The vector to scale
+ * @param scalar The scalar to multiply the vector components with
+ * @returns The new scaled vector
+export function vectorScale(v: Vector, scalar: number): Vector {
+ return vector(v[0] * scalar, v[1] * scalar);
+ * Calculates the sqare magnitude of a vector. Use this if you compare
+ * magnitudes as it saves you an SQRT.
+ * @param v The vector to measure
+ * @returns The scalar squared magnitude of the vector
+export function vectorMagnitudeSq(v: Vector) {
+ return v[0] * v[0] + v[1] * v[1];
+ * Calculates the magnitude of a vector.
+ * @returns The scalar magnitude of the vector
+export function vectorMagnitude(v: Vector) {
+ return Math.sqrt(vectorMagnitudeSq(v));
+ * Normalize the vector (i.e. make the vector magnitue equal 1).
+ * @param v The vector to normalize
+ * @returns The new normalized vector
+export const vectorNormalize = (v: Vector): Vector => {
+ const m = vectorMagnitude(v);
+ return vector(v[0] / m, v[1] / m);
@@ -0,0 +1,55 @@
+const webpack = require("webpack");
+const path = require("path");
+const BundleAnalyzerPlugin =
+ require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
+module.exports = {
+ mode: "production",
+ entry: { "excalidraw-math.min": "./index.js" },
+ output: {
+ path: path.resolve(__dirname, "dist"),
+ filename: "[name].js",
+ library: "ExcalidrawMath",
+ libraryTarget: "umd",
+ resolve: {
+ extensions: [".tsx", ".ts", ".js", ".css", ".scss"],
+ optimization: {
+ runtimeChunk: false,
+ module: {
+ rules: [
+ test: /\.(ts|tsx|js)$/,
+ use: [
+ loader: "ts-loader",
+ options: {
+ transpileOnly: true,
+ configFile: path.resolve(__dirname, "../tsconfig.prod.json"),
+ loader: "babel-loader",
+ presets: [
+ "@babel/preset-env",
+ ["@babel/preset-react", { runtime: "automatic" }],
+ "@babel/preset-typescript",
+ plugins: [["@babel/plugin-transform-runtime"]],
+ plugins: [
+ new webpack.optimize.LimitChunkCountPlugin({
+ maxChunks: 1,
+ }),
+ ...(process.env.ANALYZER === "true" ? [new BundleAnalyzerPlugin()] : []),
@@ -1,9 +1,16 @@
import type { Bounds } from "../excalidraw/element/bounds";
-import type { Point } from "../excalidraw/types";
-export type LineSegment = [Point, Point];
+export type LineSegment<P extends LocalPoint | GlobalPoint> = [P, P];
-export function getBBox(line: LineSegment): Bounds {
+export function getBBox<P extends LocalPoint | GlobalPoint>(
+ line: LineSegment<P>,
+): Bounds {
Math.min(line[0][0], line[1][0]),
Math.min(line[0][1], line[1][1]),
@@ -12,40 +19,37 @@ export function getBBox(line: LineSegment): Bounds {
-export function crossProduct(a: Point, b: Point) {
- return a[0] * b[1] - b[0] * a[1];
-}
export function doBBoxesIntersect(a: Bounds, b: Bounds) {
return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1];
-export function translate(a: Point, b: Point): Point {
- return [a[0] - b[0], a[1] - b[1]];
const EPSILON = 0.000001;
-export function isPointOnLine(l: LineSegment, p: Point) {
- const p1 = translate(l[1], l[0]);
- const p2 = translate(p, l[0]);
+export function isPointOnLine<P extends GlobalPoint | LocalPoint>(
+ l: LineSegment<P>,
+ const p1 = vectorFromPoint(l[1], l[0]);
+ const p2 = vectorFromPoint(p, l[0]);
- const r = crossProduct(p1, p2);
+ const r = vectorCross(p1, p2);
return Math.abs(r) < EPSILON;
-export function isPointRightOfLine(l: LineSegment, p: Point) {
+export function isPointRightOfLine<P extends GlobalPoint | LocalPoint>(
- return crossProduct(p1, p2) < 0;
+ return vectorCross(p1, p2) < 0;
-export function isLineSegmentTouchingOrCrossingLine(
- a: LineSegment,
- b: LineSegment,
-) {
+export function isLineSegmentTouchingOrCrossingLine<
+>(a: LineSegment<P>, b: LineSegment<P>) {
isPointOnLine(a, b[0]) ||
isPointOnLine(a, b[1]) ||
@@ -56,7 +60,10 @@ export function isLineSegmentTouchingOrCrossingLine(
// https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/
-export function doLineSegmentsIntersect(a: LineSegment, b: LineSegment) {
+export function doLineSegmentsIntersect<P extends GlobalPoint | LocalPoint>(
+ a: LineSegment<P>,
+ b: LineSegment<P>,
doBBoxesIntersect(getBBox(a), getBBox(b)) &&
isLineSegmentTouchingOrCrossingLine(a, b) &&
@@ -0,0 +1,87 @@
+import type { Curve, Degrees, GlobalPoint } from "../math";
+ curve,
+ lineSegmentRotate,
+ pointRotateDegs,
+import { pointOnCurve, pointOnPolyline } from "./collision";
+import type { Polyline } from "./geometry/shape";
+describe("point and curve", () => {
+ const c: Curve<GlobalPoint> = curve(
+ point(1.4, 1.65),
+ point(1.9, 7.9),
+ point(5.9, 1.65),
+ point(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(point(2, 4), c, 0.1)).toBe(true);
+ expect(pointOnCurve(point(4, 4.4), c, 0.1)).toBe(true);
+ expect(pointOnCurve(point(5.6, 3.85), c, 0.1)).toBe(true);
+ expect(pointOnCurve(point(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(point(1, 0), point(1, 2)),
+ lineSegment(point(1, 2), point(2, 2)),
+ lineSegment(point(2, 2), point(2, 1)),
+ lineSegment(point(2, 1), point(3, 1)),
+ it("point on the line", () => {
+ expect(pointOnPolyline(point(1, 0), polyline)).toBe(true);
+ expect(pointOnPolyline(point(1, 2), polyline)).toBe(true);
+ expect(pointOnPolyline(point(2, 2), polyline)).toBe(true);
+ expect(pointOnPolyline(point(2, 1), polyline)).toBe(true);
+ expect(pointOnPolyline(point(3, 1), polyline)).toBe(true);
+ expect(pointOnPolyline(point(1, 1), polyline)).toBe(true);
+ expect(pointOnPolyline(point(2, 1.5), polyline)).toBe(true);
+ expect(pointOnPolyline(point(2.5, 1), polyline)).toBe(true);
+ expect(pointOnPolyline(point(0, 1), polyline)).toBe(false);
+ expect(pointOnPolyline(point(2.1, 1.5), polyline)).toBe(false);
+ it("point on the line with rotation", () => {
+ const truePoints = [
+ point(1, 0),
+ point(1, 2),
+ point(2, 2),
+ point(2, 1),
+ point(3, 1),
+ truePoints.forEach((p) => {
+ const rotation = (Math.random() * 360) as Degrees;
+ const rotatedPoint = pointRotateDegs(p, point(0, 0), rotation);
+ const rotatedPolyline = polyline.map((line) =>
+ lineSegmentRotate(line, degreesToRadians(rotation), point(0, 0)),
+ expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(true);
+ const falsePoints = [point(0, 1), point(2.1, 1.5)];
+ falsePoints.forEach((p) => {
+ expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(false);
@@ -1,20 +1,26 @@
-import type { Point, Polygon, GeometricShape } from "./geometry/shape";
+import type { Polycurve, Polyline } from "./geometry/shape";
pointInEllipse,
- pointInPolygon,
- pointOnCurve,
pointOnEllipse,
- pointOnPolycurve,
+ type GeometricShape,
+} from "./geometry/shape";
+import type { Curve } from "../math";
+ polygonIncludesPoint,
pointOnPolygon,
- pointOnPolyline,
- close,
-} from "./geometry/geometry";
+ polygonFromPoints,
+ type Polygon,
// check if the given point is considered on the given shape's border
-export const isPointOnShape = (
+export const isPointOnShape = <Point extends GlobalPoint | LocalPoint>(
- shape: GeometricShape,
+ shape: GeometricShape<Point>,
tolerance = 0,
// get the distance from the given point to the given element
@@ -25,7 +31,7 @@ export const isPointOnShape = (
return pointOnEllipse(point, shape.data, tolerance);
case "line":
- return pointOnLine(point, shape.data, tolerance);
+ return pointOnLineSegment(point, shape.data, tolerance);
case "polyline":
return pointOnPolyline(point, shape.data, tolerance);
case "curve":
@@ -38,10 +44,13 @@ export const isPointOnShape = (
// check if the given point is considered inside the element's border
-export const isPointInShape = (point: Point, shape: GeometricShape) => {
+export const isPointInShape = <Point extends GlobalPoint | LocalPoint>(
switch (shape.type) {
case "polygon":
- return pointInPolygon(point, shape.data);
+ return polygonIncludesPoint(point, shape.data);
@@ -49,8 +58,8 @@ export const isPointInShape = (point: Point, shape: GeometricShape) => {
return pointInEllipse(point, shape.data);
case "polyline": {
- const polygon = close(shape.data.flat()) as Polygon;
- return pointInPolygon(point, polygon);
+ const polygon = polygonFromPoints(shape.data.flat());
+ return polygonIncludesPoint(point, polygon);
case "polycurve": {
@@ -61,6 +70,67 @@ export const isPointInShape = (point: Point, shape: GeometricShape) => {
// check if the given element is in the given bounds
-export const isPointInBounds = (point: Point, bounds: Polygon) => {
- return pointInPolygon(point, bounds);
+export const isPointInBounds = <Point extends GlobalPoint | LocalPoint>(
+ bounds: Polygon<Point>,
+ return polygonIncludesPoint(point, bounds);
+const pointOnPolycurve = <Point extends LocalPoint | GlobalPoint>(
+ polycurve: Polycurve<Point>,
+ tolerance: number,
+ return polycurve.some((curve) => pointOnCurve(point, curve, tolerance));
+const cubicBezierEquation = <Point extends LocalPoint | GlobalPoint>(
+ 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) =>
+const polyLineFromCurve = <Point extends LocalPoint | GlobalPoint>(
+ 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 = point(equation(t, 0), equation(t, 1));
+ lineSegments.push(lineSegment(startingPoint, nextPoint));
+ startingPoint = nextPoint;
+ return lineSegments;
+export const pointOnCurve = <Point extends LocalPoint | GlobalPoint>(
+ threshold: number,
+ return pointOnPolyline(point, polyLineFromCurve(curve), threshold);
+export const pointOnPolyline = <Point extends LocalPoint | GlobalPoint>(
+ polyline: Polyline<Point>,
+ threshold = 10e-5,
+ return polyline.some((line) => pointOnLineSegment(point, line, threshold));
@@ -1,249 +1,122 @@
+import type { GlobalPoint, LineSegment, Polygon, Radians } from "../../math";
- lineIntersectsLine,
- lineRotate,
- pointInEllipse,
- pointLeftofLine,
- pointOnEllipse,
+ polygon,
- pointRightofLine,
-} from "./geometry";
-import type { Curve, Ellipse, Line, Point, Polygon, Polyline } from "./shape";
+ segmentsIntersectAt,
+import { pointInEllipse, pointOnEllipse, type Ellipse } from "./shape";
describe("point and line", () => {
- const line: Line = [
- [1, 0],
- it("point on left or right of line", () => {
- expect(pointLeftofLine([0, 1], line)).toBe(true);
- expect(pointLeftofLine([1, 1], line)).toBe(false);
- expect(pointLeftofLine([2, 1], line)).toBe(false);
- expect(pointRightofLine([0, 1], line)).toBe(false);
- expect(pointRightofLine([1, 1], line)).toBe(false);
- expect(pointRightofLine([2, 1], line)).toBe(true);
+ // const l: Line<GlobalPoint> = line(point(1, 0), point(1, 2));
- it("point on the line", () => {
- expect(pointOnLine([0, 1], line)).toBe(false);
- expect(pointOnLine([1, 1], line, 0)).toBe(true);
- expect(pointOnLine([2, 1], line)).toBe(false);
+ // it("point on left or right of line", () => {
+ // expect(pointLeftofLine(point(0, 1), l)).toBe(true);
+ // expect(pointLeftofLine(point(1, 1), l)).toBe(false);
+ // expect(pointLeftofLine(point(2, 1), l)).toBe(false);
-describe("point and polylines", () => {
- const polyline: Polyline = [
- [2, 2],
- [2, 1],
- [3, 1],
+ // expect(pointRightofLine(point(0, 1), l)).toBe(false);
+ // expect(pointRightofLine(point(1, 1), l)).toBe(false);
+ // expect(pointRightofLine(point(2, 1), l)).toBe(true);
+ // });
- expect(pointOnPolyline([1, 0], polyline)).toBe(true);
- expect(pointOnPolyline([1, 2], polyline)).toBe(true);
- expect(pointOnPolyline([2, 2], polyline)).toBe(true);
- expect(pointOnPolyline([2, 1], polyline)).toBe(true);
- expect(pointOnPolyline([3, 1], polyline)).toBe(true);
- expect(pointOnPolyline([1, 1], polyline)).toBe(true);
- expect(pointOnPolyline([2, 1.5], polyline)).toBe(true);
- expect(pointOnPolyline([2.5, 1], polyline)).toBe(true);
- expect(pointOnPolyline([0, 1], polyline)).toBe(false);
- expect(pointOnPolyline([2.1, 1.5], polyline)).toBe(false);
+ const s: LineSegment<GlobalPoint> = lineSegment(point(1, 0), point(1, 2));
- it("point on the line with rotation", () => {
- const truePoints = [
- ] as Point[];
- truePoints.forEach((point) => {
- const rotation = Math.random() * 360;
- const rotatedPoint = pointRotate(point, rotation);
- const rotatedPolyline: Polyline = polyline.map((line) =>
- lineRotate(line, rotation, [0, 0]),
- expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(true);
- const falsePoints = [
- [0, 1],
- [2.1, 1.5],
- falsePoints.forEach((point) => {
- expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(false);
+ expect(pointOnLineSegment(point(0, 1), s)).toBe(false);
+ expect(pointOnLineSegment(point(1, 1), s, 0)).toBe(true);
+ expect(pointOnLineSegment(point(2, 1), s)).toBe(false);
describe("point and polygon", () => {
- const polygon: Polygon = [
- [50, 10],
- [50, 50],
- [10, 50],
+ const poly: Polygon<GlobalPoint> = polygon(
+ point(50, 10),
+ point(50, 50),
+ point(10, 50),
it("point on polygon", () => {
- expect(pointOnPolygon([30, 10], polygon)).toBe(true);
- expect(pointOnPolygon([50, 30], polygon)).toBe(true);
- expect(pointOnPolygon([30, 50], polygon)).toBe(true);
- expect(pointOnPolygon([10, 30], polygon)).toBe(true);
- expect(pointOnPolygon([30, 30], polygon)).toBe(false);
- expect(pointOnPolygon([30, 70], polygon)).toBe(false);
+ expect(pointOnPolygon(point(30, 10), poly)).toBe(true);
+ expect(pointOnPolygon(point(50, 30), poly)).toBe(true);
+ expect(pointOnPolygon(point(30, 50), poly)).toBe(true);
+ expect(pointOnPolygon(point(10, 30), poly)).toBe(true);
+ expect(pointOnPolygon(point(30, 30), poly)).toBe(false);
+ expect(pointOnPolygon(point(30, 70), poly)).toBe(false);
it("point in polygon", () => {
- [2, 0],
- [0, 2],
- expect(pointInPolygon([1, 1], polygon)).toBe(true);
- expect(pointInPolygon([3, 3], polygon)).toBe(false);
-describe("point and curve", () => {
- const curve: Curve = [
- [1.4, 1.65],
- [1.9, 7.9],
- [5.9, 1.65],
- [6.44, 4.84],
- it("point on curve", () => {
- expect(pointOnCurve(curve[0], curve)).toBe(true);
- expect(pointOnCurve(curve[3], curve)).toBe(true);
- expect(pointOnCurve([2, 4], curve, 0.1)).toBe(true);
- expect(pointOnCurve([4, 4.4], curve, 0.1)).toBe(true);
- expect(pointOnCurve([5.6, 3.85], curve, 0.1)).toBe(true);
- expect(pointOnCurve([5.6, 4], curve, 0.1)).toBe(false);
- expect(pointOnCurve(curve[1], curve, 0.1)).toBe(false);
- expect(pointOnCurve(curve[2], curve, 0.1)).toBe(false);
+ point(2, 0),
+ point(0, 2),
+ expect(polygonIncludesPoint(point(1, 1), poly)).toBe(true);
+ expect(polygonIncludesPoint(point(3, 3), poly)).toBe(false);
describe("point and ellipse", () => {
- const ellipse: Ellipse = {
- center: [0, 0],
+ const ellipse: Ellipse<GlobalPoint> = {
+ center: point(0, 0),
halfWidth: 2,
halfHeight: 1,
it("point on ellipse", () => {
- [0, -1],
- [-2, 0],
- ].forEach((point) => {
- expect(pointOnEllipse(point as Point, ellipse)).toBe(true);
+ [point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => {
+ expect(pointOnEllipse(p, ellipse)).toBe(true);
- expect(pointOnEllipse([-1.4, 0.7], ellipse, 0.1)).toBe(true);
- expect(pointOnEllipse([-1.4, 0.71], ellipse, 0.01)).toBe(true);
+ expect(pointOnEllipse(point(-1.4, 0.7), ellipse, 0.1)).toBe(true);
+ expect(pointOnEllipse(point(-1.4, 0.71), ellipse, 0.01)).toBe(true);
- expect(pointOnEllipse([1.4, 0.7], ellipse, 0.1)).toBe(true);
- expect(pointOnEllipse([1.4, 0.71], ellipse, 0.01)).toBe(true);
+ expect(pointOnEllipse(point(1.4, 0.7), ellipse, 0.1)).toBe(true);
+ expect(pointOnEllipse(point(1.4, 0.71), ellipse, 0.01)).toBe(true);
- expect(pointOnEllipse([1, -0.86], ellipse, 0.1)).toBe(true);
- expect(pointOnEllipse([1, -0.86], ellipse, 0.01)).toBe(true);
+ expect(pointOnEllipse(point(1, -0.86), ellipse, 0.1)).toBe(true);
+ expect(pointOnEllipse(point(1, -0.86), ellipse, 0.01)).toBe(true);
- expect(pointOnEllipse([-1, -0.86], ellipse, 0.1)).toBe(true);
- expect(pointOnEllipse([-1, -0.86], ellipse, 0.01)).toBe(true);
+ expect(pointOnEllipse(point(-1, -0.86), ellipse, 0.1)).toBe(true);
+ expect(pointOnEllipse(point(-1, -0.86), ellipse, 0.01)).toBe(true);
- expect(pointOnEllipse([-1, 0.8], ellipse)).toBe(false);
- expect(pointOnEllipse([1, -0.8], ellipse)).toBe(false);
+ expect(pointOnEllipse(point(-1, 0.8), ellipse)).toBe(false);
+ expect(pointOnEllipse(point(1, -0.8), ellipse)).toBe(false);
it("point in ellipse", () => {
- expect(pointInEllipse(point as Point, ellipse)).toBe(true);
+ expect(pointInEllipse(p, ellipse)).toBe(true);
- expect(pointInEllipse([-1, 0.8], ellipse)).toBe(true);
- expect(pointInEllipse([1, -0.8], ellipse)).toBe(true);
+ expect(pointInEllipse(point(-1, 0.8), ellipse)).toBe(true);
+ expect(pointInEllipse(point(1, -0.8), ellipse)).toBe(true);
- expect(pointInEllipse([-1, 1], ellipse)).toBe(false);
- expect(pointInEllipse([-1.4, 0.8], ellipse)).toBe(false);
+ expect(pointInEllipse(point(-1, 1), ellipse)).toBe(false);
+ expect(pointInEllipse(point(-1.4, 0.8), ellipse)).toBe(false);
describe("line and line", () => {
- const lineA: Line = [
- [1, 4],
- const lineB: Line = [
- [2, 7],
- const lineC: Line = [
- [1, 8],
- [3, 8],
- const lineD: Line = [
- const lineE: Line = [
- [1, 9],
- [3, 9],
- const lineF: Line = [
- const lineG: Line = [
- [2, 3],
+ const lineA: LineSegment<GlobalPoint> = lineSegment(point(1, 4), point(3, 4));
+ const lineB: LineSegment<GlobalPoint> = lineSegment(point(2, 1), point(2, 7));
+ const lineC: LineSegment<GlobalPoint> = lineSegment(point(1, 8), point(3, 8));
+ const lineD: LineSegment<GlobalPoint> = lineSegment(point(1, 8), point(3, 8));
+ const lineE: LineSegment<GlobalPoint> = lineSegment(point(1, 9), point(3, 9));
+ const lineF: LineSegment<GlobalPoint> = lineSegment(point(1, 2), point(3, 4));
+ const lineG: LineSegment<GlobalPoint> = lineSegment(point(0, 1), point(2, 3));
it("intersection", () => {
- expect(lineIntersectsLine(lineA, lineB)).toBe(true);
- expect(lineIntersectsLine(lineA, lineC)).toBe(false);
- expect(lineIntersectsLine(lineB, lineC)).toBe(false);
- expect(lineIntersectsLine(lineC, lineD)).toBe(true);
- expect(lineIntersectsLine(lineE, lineD)).toBe(false);
- expect(lineIntersectsLine(lineF, lineG)).toBe(true);
+ expect(segmentsIntersectAt(lineA, lineB)).toEqual([2, 4]);
+ expect(segmentsIntersectAt(lineA, lineC)).toBe(null);
+ expect(segmentsIntersectAt(lineB, lineC)).toBe(null);
+ expect(segmentsIntersectAt(lineC, lineD)).toBe(null); // Line overlapping line is not intersection!
+ expect(segmentsIntersectAt(lineE, lineD)).toBe(null);
+ expect(segmentsIntersectAt(lineF, lineG)).toBe(null);
@@ -1,1060 +0,0 @@
-import type { ExcalidrawBindableElement } from "../../excalidraw/element/types";
- addVectors,
- subtractVectors,
-} from "../../excalidraw/math";
-import type { LineSegment } from "../bbox";
-import { crossProduct } from "../bbox";
- Line,
- Polygon,
- Curve,
- Ellipse,
- Polycurve,
- Polyline,
-} from "./shape";
-const DEFAULT_THRESHOLD = 10e-5;
- * utils
-// the two vectors are ao and bo
-export const cross = (
- a: Readonly<Point>,
- b: Readonly<Point>,
- o: Readonly<Point>,
- return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
-export const dot = (
- return (a[0] - o[0]) * (b[0] - o[0]) + (a[1] - o[1]) * (b[1] - o[1]);
-export const isClosed = (polygon: Polygon) => {
- const first = polygon[0];
- const last = polygon[polygon.length - 1];
- return first[0] === last[0] && first[1] === last[1];
-export const close = (polygon: Polygon) => {
- return isClosed(polygon) ? polygon : [...polygon, polygon[0]];
- * angles
-// convert radians to degress
-export const angleToDegrees = (angle: number) => {
- const theta = (angle * 180) / Math.PI;
- return theta < 0 ? 360 + theta : theta;
-// convert degrees to radians
-export const angleToRadians = (angle: number) => {
- return (angle / 180) * Math.PI;
-// return the angle of reflection given an angle of incidence and a surface angle in degrees
-export const angleReflect = (incidenceAngle: number, surfaceAngle: number) => {
- const a = surfaceAngle * 2 - incidenceAngle;
- return a >= 360 ? a - 360 : a < 0 ? a + 360 : a;
- * points
-const rotate = (point: Point, angle: number): Point => {
- point[0] * Math.cos(angle) - point[1] * Math.sin(angle),
- point[0] * Math.sin(angle) + point[1] * Math.cos(angle),
-const isOrigin = (point: Point) => {
- return point[0] === 0 && point[1] === 0;
-// rotate a given point about a given origin at the given angle
-export const pointRotate = (
- origin?: Point,
- const r = angleToRadians(angle);
- if (!origin || isOrigin(origin)) {
- return rotate(point, r);
- return rotate(point.map((c, i) => c - origin[i]) as Point, r).map(
- (c, i) => c + origin[i],
-// translate a point by an angle (in degrees) and distance
-export const pointTranslate = (point: Point, angle = 0, distance = 0) => {
- point[0] + distance * Math.cos(r),
- point[1] + distance * Math.sin(r),
-export const pointInverse = (point: Point) => {
- return [-point[0], -point[1]] as Point;
-export const pointAdd = (pointA: Point, pointB: Point): Point => {
- return [pointA[0] + pointB[0], pointA[1] + pointB[1]];
-export const distanceToPoint = (p1: Point, p2: Point) => {
- return distance2d(...p1, ...p2);
- * lines
-// return the angle of a line, in degrees
-export const lineAngle = (line: Line) => {
- return angleToDegrees(
- Math.atan2(line[1][1] - line[0][1], line[1][0] - line[0][0]),
-// get the distance between the endpoints of a line segment
-export const lineLength = (line: Line) => {
- return Math.sqrt(
- Math.pow(line[1][0] - line[0][0], 2) + Math.pow(line[1][1] - line[0][1], 2),
-// get the midpoint of a line segment
-export const lineMidpoint = (line: Line) => {
- (line[0][0] + line[1][0]) / 2,
- (line[0][1] + line[1][1]) / 2,
-// return the coordinates resulting from rotating the given line about an origin by an angle in degrees
-// note that when the origin is not given, the midpoint of the given line is used as the origin
-export const lineRotate = (line: Line, angle: number, origin?: Point): Line => {
- return line.map((point) =>
- pointRotate(point, angle, origin || lineMidpoint(line)),
- ) as Line;
-// returns the coordinates resulting from translating a line by an angle in degrees and a distance.
-export const lineTranslate = (line: Line, angle: number, distance: number) => {
- return line.map((point) => pointTranslate(point, angle, distance));
-export const lineInterpolate = (line: Line, clamp = false) => {
- const [[x1, y1], [x2, y2]] = line;
- return (t: number) => {
- const t0 = clamp ? (t < 0 ? 0 : t > 1 ? 1 : t) : t;
- return [(x2 - x1) * t0 + x1, (y2 - y1) * t0 + y1] as Point;
- * curves
-function clone(p: Point): Point {
- return [...p] as Point;
-export const curveToBezier = (
- pointsIn: readonly Point[],
- curveTightness = 0,
- const len = pointsIn.length;
- if (len < 3) {
- throw new Error("A curve must have at least three points.");
- const out: Point[] = [];
- if (len === 3) {
- out.push(
- clone(pointsIn[0]),
- clone(pointsIn[1]),
- clone(pointsIn[2]),
- const points: Point[] = [];
- points.push(pointsIn[0], pointsIn[0]);
- for (let i = 1; i < pointsIn.length; i++) {
- points.push(pointsIn[i]);
- if (i === pointsIn.length - 1) {
- const b: Point[] = [];
- const s = 1 - curveTightness;
- out.push(clone(points[0]));
- for (let i = 1; i + 2 < points.length; i++) {
- const cachedVertArray = points[i];
- b[0] = [cachedVertArray[0], cachedVertArray[1]];
- b[1] = [
- cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6,
- cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6,
- b[2] = [
- points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6,
- points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6,
- b[3] = [points[i + 1][0], points[i + 1][1]];
- out.push(b[1], b[2], b[3]);
- return out;
-export const curveRotate = (curve: Curve, angle: number, origin: Point) => {
- return curve.map((p) => pointRotate(p, angle, origin));
-export const cubicBezierPoint = (t: number, controlPoints: Curve): Point => {
- const [p0, p1, p2, p3] = controlPoints;
- const x =
- Math.pow(1 - t, 3) * p0[0] +
- 3 * Math.pow(1 - t, 2) * t * p1[0] +
- 3 * (1 - t) * Math.pow(t, 2) * p2[0] +
- Math.pow(t, 3) * p3[0];
- const y =
- Math.pow(1 - t, 3) * p0[1] +
- 3 * Math.pow(1 - t, 2) * t * p1[1] +
- 3 * (1 - t) * Math.pow(t, 2) * p2[1] +
- Math.pow(t, 3) * p3[1];
-const solveCubicEquation = (a: number, b: number, c: number, d: number) => {
- // This function solves the cubic equation ax^3 + bx^2 + cx + d = 0
- const roots: number[] = [];
- const discriminant =
- 18 * a * b * c * d -
- 4 * Math.pow(b, 3) * d +
- Math.pow(b, 2) * Math.pow(c, 2) -
- 4 * a * Math.pow(c, 3) -
- 27 * Math.pow(a, 2) * Math.pow(d, 2);
- if (discriminant >= 0) {
- const C = Math.cbrt((discriminant + Math.sqrt(discriminant)) / 2);
- const D = Math.cbrt((discriminant - Math.sqrt(discriminant)) / 2);
- const root1 = (-b - C - D) / (3 * a);
- const root2 = (-b + (C + D) / 2) / (3 * a);
- const root3 = (-b + (C + D) / 2) / (3 * a);
- roots.push(root1, root2, root3);
- const realPart = -b / (3 * a);
- const root1 =
- 2 * Math.sqrt(-b / (3 * a)) * Math.cos(Math.acos(realPart) / 3);
- const root2 =
- 2 *
- Math.sqrt(-b / (3 * a)) *
- Math.cos((Math.acos(realPart) + 2 * Math.PI) / 3);
- const root3 =
- Math.cos((Math.acos(realPart) + 4 * Math.PI) / 3);
- return roots;
-const findClosestParameter = (point: Point, controlPoints: Curve) => {
- // This function finds the parameter t that minimizes the distance between the point
- // and any point on the cubic Bezier curve.
- // Use the direct formula to find the parameter t
- const a = p3[0] - 3 * p2[0] + 3 * p1[0] - p0[0];
- const b = 3 * p2[0] - 6 * p1[0] + 3 * p0[0];
- const c = 3 * p1[0] - 3 * p0[0];
- const d = p0[0] - point[0];
- const rootsX = solveCubicEquation(a, b, c, d);
- // Do the same for the y-coordinate
- const e = p3[1] - 3 * p2[1] + 3 * p1[1] - p0[1];
- const f = 3 * p2[1] - 6 * p1[1] + 3 * p0[1];
- const g = 3 * p1[1] - 3 * p0[1];
- const h = p0[1] - point[1];
- const rootsY = solveCubicEquation(e, f, g, h);
- // Select the real root that is between 0 and 1 (inclusive)
- const validRootsX = rootsX.filter((root) => root >= 0 && root <= 1);
- const validRootsY = rootsY.filter((root) => root >= 0 && root <= 1);
- if (validRootsX.length === 0 || validRootsY.length === 0) {
- // No valid roots found, use the midpoint as a fallback
- return 0.5;
- // Choose the parameter t that minimizes the distance
- let closestT = 0;
- for (const rootX of validRootsX) {
- for (const rootY of validRootsY) {
- const distance = Math.sqrt(
- (rootX - point[0]) ** 2 + (rootY - point[1]) ** 2,
- closestT = (rootX + rootY) / 2; // Use the average for a smoother result
- return closestT;
-export const cubicBezierDistance = (point: Point, controlPoints: Curve) => {
- // Calculate the closest point on the Bezier curve to the given point
- const t = findClosestParameter(point, controlPoints);
- // Calculate the coordinates of the closest point on the curve
- const [closestX, closestY] = cubicBezierPoint(t, controlPoints);
- // Calculate the distance between the given point and the closest point on the curve
- (point[0] - closestX) ** 2 + (point[1] - closestY) ** 2,
- return distance;
- * polygons
-export const polygonRotate = (
- polygon: Polygon,
- origin: Point,
- return polygon.map((p) => pointRotate(p, angle, origin));
-export const polygonBounds = (polygon: Polygon) => {
- let xMin = Infinity;
- let xMax = -Infinity;
- let yMin = Infinity;
- let yMax = -Infinity;
- for (let i = 0, l = polygon.length; i < l; i++) {
- const p = polygon[i];
- const x = p[0];
- const y = p[1];
- if (x != null && isFinite(x) && y != null && isFinite(y)) {
- if (x < xMin) {
- xMin = x;
- if (x > xMax) {
- xMax = x;
- if (y < yMin) {
- yMin = y;
- if (y > yMax) {
- yMax = y;
- [xMin, yMin],
- [xMax, yMax],
- ] as [Point, Point];
-export const polygonCentroid = (vertices: Point[]) => {
- let a = 0;
- let x = 0;
- let y = 0;
- const l = vertices.length;
- for (let i = 0; i < l; i++) {
- const s = i === l - 1 ? 0 : i + 1;
- const v0 = vertices[i];
- const v1 = vertices[s];
- const f = v0[0] * v1[1] - v1[0] * v0[1];
- a += f;
- x += (v0[0] + v1[0]) * f;
- y += (v0[1] + v1[1]) * f;
- const d = a * 3;
- return [x / d, y / d] as Point;
-export const polygonScale = (
- scale: number,
- if (!origin) {
- origin = polygonCentroid(polygon);
- const p: Polygon = [];
- const v = polygon[i];
- const d = lineLength([origin, v]);
- const a = lineAngle([origin, v]);
- p[i] = pointTranslate(origin, a, d * scale);
- return p;
-export const polygonScaleX = (
- const t = pointTranslate(origin, a, d * scale);
- p[i] = [t[0], v[1]];
-export const polygonScaleY = (
- p[i] = [v[0], t[1]];
-export const polygonReflectX = (polygon: Polygon, reflectFactor = 1) => {
- const [[min], [max]] = polygonBounds(polygon);
- const p: Point[] = [];
- const [x, y] = polygon[i];
- const r: Point = [min + max - x, y];
- if (reflectFactor === 0) {
- p[i] = [x, y];
- } else if (reflectFactor === 1) {
- p[i] = r;
- const t = lineInterpolate([[x, y], r]);
- p[i] = t(Math.max(Math.min(reflectFactor, 1), 0));
-export const polygonReflectY = (polygon: Polygon, reflectFactor = 1) => {
- const [[, min], [, max]] = polygonBounds(polygon);
- const r: Point = [x, min + max - y];
-export const polygonTranslate = (
- distance: number,
- return polygon.map((p) => pointTranslate(p, angle, distance));
- * ellipses
-export const ellipseAxes = (ellipse: Ellipse) => {
- const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight;
- const majorAxis = widthGreaterThanHeight
- ? ellipse.halfWidth * 2
- : ellipse.halfHeight * 2;
- const minorAxis = widthGreaterThanHeight
- ? ellipse.halfHeight * 2
- : ellipse.halfWidth * 2;
- majorAxis,
- minorAxis,
-export const ellipseFocusToCenter = (ellipse: Ellipse) => {
- const { majorAxis, minorAxis } = ellipseAxes(ellipse);
- return Math.sqrt(majorAxis ** 2 - minorAxis ** 2);
-export const ellipseExtremes = (ellipse: Ellipse) => {
- const { center, angle } = ellipse;
- const sqSum = majorAxis ** 2 + minorAxis ** 2;
- const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle);
- const yMax = Math.sqrt((sqSum - sqDiff) / 2);
- const xAtYMax =
- (yMax * sqSum * sin * cos) /
- (majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2);
- const xMax = Math.sqrt((sqSum + sqDiff) / 2);
- const yAtXMax =
- (xMax * sqSum * sin * cos) /
- (majorAxis ** 2 * cos ** 2 + minorAxis ** 2 * sin ** 2);
- pointAdd([xAtYMax, yMax], center),
- pointAdd(pointInverse([xAtYMax, yMax]), center),
- pointAdd([xMax, yAtXMax], center),
-export const pointRelativeToCenter = (
- const translated = pointAdd(point, pointInverse(center));
- const rotated = pointRotate(translated, -angleToDegrees(angle));
- return rotated;
- * relationships
-const topPointFirst = (line: Line) => {
- return line[1][1] > line[0][1] ? line : [line[1], line[0]];
-export const pointLeftofLine = (point: Point, line: Line) => {
- const t = topPointFirst(line);
- return cross(point, t[1], t[0]) < 0;
-export const pointRightofLine = (point: Point, line: Line) => {
- return cross(point, t[1], t[0]) > 0;
-export const distanceToSegment = (point: Point, line: Line) => {
- const [x, y] = point;
- const A = x - x1;
- const B = y - y1;
- const C = x2 - x1;
- const D = y2 - y1;
- const dot = A * C + B * D;
- const len_sq = C * C + D * D;
- let param = -1;
- if (len_sq !== 0) {
- param = dot / len_sq;
- let xx;
- let yy;
- if (param < 0) {
- xx = x1;
- yy = y1;
- } else if (param > 1) {
- xx = x2;
- yy = y2;
- xx = x1 + param * C;
- yy = y1 + param * D;
- const dx = x - xx;
- const dy = y - yy;
- return Math.sqrt(dx * dx + dy * dy);
-export const pointOnLine = (
- line: Line,
- threshold = DEFAULT_THRESHOLD,
- const distance = distanceToSegment(point, line);
- if (distance === 0) {
- return distance < threshold;
-export const pointOnPolyline = (
- polyline: Polyline,
- return polyline.some((line) => pointOnLine(point, line, threshold));
-export const lineIntersectsLine = (lineA: Line, lineB: Line) => {
- const [[a0x, a0y], [a1x, a1y]] = lineA;
- const [[b0x, b0y], [b1x, b1y]] = lineB;
- // shared points
- if (a0x === b0x && a0y === b0y) {
- if (a1x === b1x && a1y === b1y) {
- // point on line
- if (pointOnLine(lineA[0], lineB) || pointOnLine(lineA[1], lineB)) {
- if (pointOnLine(lineB[0], lineA) || pointOnLine(lineB[1], lineA)) {
- const denom = (b1y - b0y) * (a1x - a0x) - (b1x - b0x) * (a1y - a0y);
- if (denom === 0) {
- const deltaY = a0y - b0y;
- const deltaX = a0x - b0x;
- const numer0 = (b1x - b0x) * deltaY - (b1y - b0y) * deltaX;
- const numer1 = (a1x - a0x) * deltaY - (a1y - a0y) * deltaX;
- const quotA = numer0 / denom;
- const quotB = numer1 / denom;
- return quotA > 0 && quotA < 1 && quotB > 0 && quotB < 1;
-export const lineIntersectsPolygon = (line: Line, polygon: Polygon) => {
- let intersects = false;
- const closed = close(polygon);
- for (let i = 0, l = closed.length - 1; i < l; i++) {
- const v0 = closed[i];
- const v1 = closed[i + 1];
- lineIntersectsLine(line, [v0, v1]) ||
- (pointOnLine(v0, line) && pointOnLine(v1, line))
- intersects = true;
- break;
- return intersects;
-export const pointInBezierEquation = (
- [mx, my]: Point,
- lineThreshold: number,
- // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
- const lineSegmentPoints: Point[] = [];
- let t = 0;
- while (t <= 1.0) {
- const diff = Math.sqrt(Math.pow(tx - mx, 2) + Math.pow(ty - my, 2));
- if (diff < lineThreshold) {
- lineSegmentPoints.push([tx, ty]);
- t += 0.1;
- // check the distance from line segments to the given point
-export const cubicBezierEquation = (curve: Curve) => {
- const [p0, p1, p2, p3] = curve;
- return (t: number, idx: number) =>
-export const polyLineFromCurve = (curve: Curve, segments = 10): Polyline => {
- const equation = cubicBezierEquation(curve);
- let startingPoint = [equation(0, 0), equation(0, 1)] as Point;
- const lineSegments: Polyline = [];
- const increment = 1 / segments;
- for (let i = 0; i < segments; i++) {
- t += increment;
- if (t <= 1) {
- const nextPoint: Point = [equation(t, 0), equation(t, 1)];
- lineSegments.push([startingPoint, nextPoint]);
- startingPoint = nextPoint;
- return lineSegments;
-export const pointOnCurve = (
- curve: Curve,
- return pointOnPolyline(point, polyLineFromCurve(curve), threshold);
-export const pointOnPolycurve = (
- polycurve: Polycurve,
- return polycurve.some((curve) => pointOnCurve(point, curve, threshold));
-export const pointInPolygon = (point: Point, polygon: Polygon) => {
- const x = point[0];
- const y = point[1];
- let inside = false;
- for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
- const xi = polygon[i][0];
- const yi = polygon[i][1];
- const xj = polygon[j][0];
- const yj = polygon[j][1];
- ((yi > y && yj <= y) || (yi <= y && yj > y)) &&
- x < ((xj - xi) * (y - yi)) / (yj - yi) + xi
- inside = !inside;
- return inside;
-export const pointOnPolygon = (
- let on = false;
- if (pointOnLine(point, [closed[i], closed[i + 1]], threshold)) {
- on = true;
- return on;
-export const polygonInPolygon = (polygonA: Polygon, polygonB: Polygon) => {
- let inside = true;
- const closed = close(polygonA);
- // Points test
- if (!pointInPolygon(v0, polygonB)) {
- inside = false;
- // Lines test
- if (lineIntersectsPolygon([v0, closed[i + 1]], polygonB)) {
-export const polygonIntersectPolygon = (
- polygonA: Polygon,
- polygonB: Polygon,
- let onCount = 0;
- if (lineIntersectsPolygon([v0, v1], polygonB)) {
- if (pointOnPolygon(v0, polygonB)) {
- ++onCount;
- if (onCount === 2) {
-const distanceToEllipse = (point: Point, ellipse: Ellipse) => {
- const { angle, halfWidth, halfHeight, center } = ellipse;
- const a = halfWidth;
- const b = halfHeight;
- const [rotatedPointX, rotatedPointY] = pointRelativeToCenter(
- angle,
- const px = Math.abs(rotatedPointX);
- const py = Math.abs(rotatedPointY);
- let tx = 0.707;
- let ty = 0.707;
- for (let i = 0; i < 3; i++) {
- const x = a * tx;
- const y = b * ty;
- const ex = ((a * a - b * b) * tx ** 3) / a;
- const ey = ((b * b - a * a) * ty ** 3) / b;
- const rx = x - ex;
- const ry = y - ey;
- const qx = px - ex;
- const qy = py - ey;
- const r = Math.hypot(ry, rx);
- const q = Math.hypot(qy, qx);
- tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
- ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
- const t = Math.hypot(ty, tx);
- tx /= t;
- ty /= t;
- const [minX, minY] = [
- a * tx * Math.sign(rotatedPointX),
- b * ty * Math.sign(rotatedPointY),
- return distanceToPoint([rotatedPointX, rotatedPointY], [minX, minY]);
-export const pointOnEllipse = (
- ellipse: Ellipse,
- return distanceToEllipse(point, ellipse) <= threshold;
-export const pointInEllipse = (point: Point, ellipse: Ellipse) => {
- const { center, angle, halfWidth, halfHeight } = ellipse;
- (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) +
- (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <=
- 1
- * Calculates the point two line segments with a definite start and end point
- * intersect at.
-export const segmentsIntersectAt = (
- a: Readonly<LineSegment>,
- b: Readonly<LineSegment>,
- const r = subtractVectors(a[1], a[0]);
- const s = subtractVectors(b[1], b[0]);
- const denominator = crossProduct(r, s);
- if (denominator === 0) {
- const i = subtractVectors(b[0], a[0]);
- const u = crossProduct(i, r) / denominator;
- const t = crossProduct(i, s) / denominator;
- if (u === 0) {
- const p = addVectors(a[0], scaleVector(r, t));
- if (t >= 0 && t < 1 && u >= 0 && u < 1) {
- * Determine intersection of a rectangular shaped element and a
- * line segment.
- *
- * @param element The rectangular element to test against
- * @param segment The segment intersecting the element
- * @param gap Optional value to inflate the shape before testing
- * @returns An array of intersections
-// TODO: Replace with final rounded rectangle code
-export const segmentIntersectRectangleElement = (
- element: ExcalidrawBindableElement,
- gap: number = 0,
- element.x - gap,
- element.y - gap,
- element.x + element.width + gap,
- element.y + element.height + gap,
- const center = [
- (bounds[0] + bounds[2]) / 2,
- (bounds[1] + bounds[3]) / 2,
- rotatePoint([bounds[0], bounds[1]], center, element.angle),
- rotatePoint([bounds[2], bounds[1]], center, element.angle),
- ] as LineSegment,
- rotatePoint([bounds[2], bounds[3]], center, element.angle),
- rotatePoint([bounds[0], bounds[3]], center, element.angle),
- .map((s) => segmentsIntersectAt(segment, s))
- .filter((i): i is Point => !!i);
@@ -12,9 +12,30 @@
* to pure shapes
+import type { Curve, LineSegment, Polygon, Radians } from "../../math";
+ PRECISION,
import { getElementAbsoluteCoords } from "../../excalidraw/element";
+ ExcalidrawBindableElement,
@@ -28,67 +49,54 @@ import type {
ExcalidrawSelectionElement,
} from "../../excalidraw/element/types";
-import { angleToDegrees, close, pointAdd, pointRotate } from "./geometry";
import { pointsOnBezierCurves } from "points-on-curve";
-// a point is specified by its coordinate (x, y)
-export type Point = [number, number];
-export type Vector = Point;
-// a line (segment) is defined by two endpoints
-export type Line = [Point, Point];
+import { invariant } from "../../excalidraw/utils";
// a polyline (made up term here) is a line consisting of other line segments
// this corresponds to a straight line element in the editor but it could also
// be used to model other elements
-export type Polyline = Line[];
-// cubic bezier curve with four control points
-export type Curve = [Point, Point, Point, Point];
+export type Polyline<Point extends GlobalPoint | LocalPoint> =
+ LineSegment<Point>[];
// a polycurve is a curve consisting of ther curves, this corresponds to a complex
// curve on the canvas
-export type Polycurve = Curve[];
-// a polygon is a closed shape by connecting the given points
-// rectangles and diamonds are modelled by polygons
-export type Polygon = Point[];
+export type Polycurve<Point extends GlobalPoint | LocalPoint> = Curve<Point>[];
// an ellipse is specified by its center, angle, and its major and minor axes
// but for the sake of simplicity, we've used halfWidth and halfHeight instead
// in replace of semi major and semi minor axes
-export type Ellipse = {
+export type Ellipse<Point extends GlobalPoint | LocalPoint> = {
center: Point;
halfWidth: number;
halfHeight: number;
-export type GeometricShape =
+export type GeometricShape<Point extends GlobalPoint | LocalPoint> =
| {
type: "line";
- data: Line;
+ data: LineSegment<Point>;
type: "polygon";
- data: Polygon;
+ data: Polygon<Point>;
type: "curve";
- data: Curve;
+ data: Curve<Point>;
type: "ellipse";
- data: Ellipse;
+ data: Ellipse<Point>;
type: "polyline";
- data: Polyline;
+ data: Polyline<Point>;
type: "polycurve";
- data: Polycurve;
+ data: Polycurve<Point>;
type RectangularElement =
@@ -102,32 +110,32 @@ type RectangularElement =
| ExcalidrawSelectionElement;
// polygon
-export const getPolygonShape = (
+export const getPolygonShape = <Point extends GlobalPoint | LocalPoint>(
element: RectangularElement,
const { angle, width, height, x, y } = element;
- const angleInDegrees = angleToDegrees(angle);
const cx = x + width / 2;
const cy = y + height / 2;
+ const center: Point = point(cx, cy);
- let data: Polygon = [];
+ let data: Polygon<Point>;
- data = [
- pointRotate([cx, y], angleInDegrees, center),
- pointRotate([x + width, cy], angleInDegrees, center),
- pointRotate([cx, y + height], angleInDegrees, center),
- pointRotate([x, cy], angleInDegrees, center),
- ] as Polygon;
+ data = polygon(
+ pointRotateRads(point(cx, y), center, angle),
+ pointRotateRads(point(x + width, cy), center, angle),
+ pointRotateRads(point(cx, y + height), center, angle),
+ pointRotateRads(point(x, cy), center, angle),
- pointRotate([x, y], angleInDegrees, center),
- pointRotate([x + width, y], angleInDegrees, center),
- pointRotate([x + width, y + height], angleInDegrees, center),
- pointRotate([x, y + height], angleInDegrees, center),
+ pointRotateRads(point(x, y), center, angle),
+ pointRotateRads(point(x + width, y), center, angle),
+ pointRotateRads(point(x + width, y + height), center, angle),
+ pointRotateRads(point(x, y + height), center, angle),
@@ -137,7 +145,7 @@ export const getPolygonShape = (
// return the selection box for an element, possibly rotated as well
-export const getSelectionBoxShape = (
+export const getSelectionBoxShape = <Point extends GlobalPoint | LocalPoint>(
padding = 10,
@@ -153,29 +161,29 @@ export const getSelectionBoxShape = (
y1 -= padding;
y2 += padding;
- const angleInDegrees = angleToDegrees(element.angle);
+ //const angleInDegrees = angleToDegrees(element.angle);
+ const center = point(cx, cy);
+ const topLeft = pointRotateRads(point(x1, y1), center, element.angle);
+ const topRight = pointRotateRads(point(x2, y1), center, element.angle);
+ const bottomLeft = pointRotateRads(point(x1, y2), center, element.angle);
+ const bottomRight = pointRotateRads(point(x2, y2), center, element.angle);
data: [topLeft, topRight, bottomRight, bottomLeft],
- } as GeometricShape;
+ } as GeometricShape<Point>;
// ellipse
-export const getEllipseShape = (
+export const getEllipseShape = <Point extends GlobalPoint | LocalPoint>(
const { width, height, angle, x, y } = element;
type: "ellipse",
data: {
- center: [x + width / 2, y + height / 2],
+ center: point(x + width / 2, y + height / 2),
halfWidth: width / 2,
halfHeight: height / 2,
@@ -193,32 +201,34 @@ export const getCurvePathOps = (shape: Drawable): Op[] => {
// linear
-export const getCurveShape = (
+export const getCurveShape = <Point extends GlobalPoint | LocalPoint>(
roughShape: Drawable,
- startingPoint: Point = [0, 0],
- angleInRadian: number,
+ startingPoint: Point = point(0, 0),
+ angleInRadian: Radians,
- const transform = (p: Point) =>
- pointRotate(
- [p[0] + startingPoint[0], p[1] + startingPoint[1]],
- angleToDegrees(angleInRadian),
+ const transform = (p: Point): Point =>
+ point(p[0] + startingPoint[0], p[1] + startingPoint[1]),
+ angleInRadian,
const ops = getCurvePathOps(roughShape);
- const polycurve: Polycurve = [];
+ const polycurve: Polycurve<Point> = [];
+ let p0 = point<Point>(0, 0);
for (const op of ops) {
if (op.op === "move") {
- p0 = transform(op.data as Point);
+ const p = pointFromArray<Point>(op.data);
+ invariant(p != null, "Ops data is not a point");
+ p0 = transform(p);
if (op.op === "bcurveTo") {
- const p1: Point = transform([op.data[0], op.data[1]]);
- const p2: Point = transform([op.data[2], op.data[3]]);
- const p3: Point = transform([op.data[4], op.data[5]]);
- polycurve.push([p0, p1, p2, p3]);
+ const p1 = transform(point<Point>(op.data[0], op.data[1]));
+ const p2 = transform(point<Point>(op.data[2], op.data[3]));
+ const p3 = transform(point<Point>(op.data[4], op.data[5]));
+ polycurve.push(curve<Point>(p0, p1, p2, p3));
p0 = p3;
@@ -229,61 +239,72 @@ export const getCurveShape = (
-const polylineFromPoints = (points: Point[]) => {
- let previousPoint = points[0];
- const polyline: Polyline = [];
+const polylineFromPoints = <Point extends GlobalPoint | LocalPoint>(
+ let previousPoint: Point = points[0];
+ const polyline: LineSegment<Point>[] = [];
for (let i = 1; i < points.length; i++) {
const nextPoint = points[i];
- polyline.push([previousPoint, nextPoint]);
+ polyline.push(lineSegment<Point>(previousPoint, nextPoint));
previousPoint = nextPoint;
return polyline;
-export const getFreedrawShape = (
+export const getFreedrawShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawFreeDrawElement,
isClosed: boolean = false,
- const angle = angleToDegrees(element.angle);
const transform = (p: Point) =>
- pointRotate(pointAdd(p, [element.x, element.y] as Point), angle, center);
+ pointFromVector(
+ vectorAdd(vectorFromPoint(p), vector(element.x, element.y)),
const polyline = polylineFromPoints(
element.points.map((p) => transform(p as Point)),
- return isClosed
- ? {
- type: "polygon",
- data: close(polyline.flat()) as Polygon,
- : {
- type: "polyline",
- data: polyline,
+ isClosed
+ ? {
+ type: "polygon",
+ data: polygonFromPoints(polyline.flat()),
+ : {
+ type: "polyline",
+ data: polyline,
+ ) as GeometricShape<Point>;
-export const getClosedCurveShape = (
+export const getClosedCurveShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawLinearElement,
+ startingPoint: Point = point<Point>(0, 0),
if (element.roundness === null) {
- data: close(element.points.map((p) => transform(p as Point))),
+ data: polygonFromPoints(
+ element.points.map((p) => transform(p as Point)) as Point[],
@@ -295,27 +316,218 @@ export const getClosedCurveShape = (
if (operation.op === "move") {
odd = !odd;
if (odd) {
- points.push([operation.data[0], operation.data[1]]);
+ points.push(point(operation.data[0], operation.data[1]));
} else if (operation.op === "bcurveTo") {
- points.push([operation.data[2], operation.data[3]]);
- points.push([operation.data[4], operation.data[5]]);
+ points.push(point(operation.data[2], operation.data[3]));
+ points.push(point(operation.data[4], operation.data[5]));
} else if (operation.op === "lineTo") {
const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) =>
- transform(p),
+ transform(p as Point),
+ ) as Point[];
- data: polygonPoints,
+ data: polygonFromPoints<Point>(polygonPoints),
+ * Determine intersection of a rectangular shaped element and a
+ * line segment.
+ * @param element The rectangular element to test against
+ * @param segment The segment intersecting the element
+ * @param gap Optional value to inflate the shape before testing
+ * @returns An array of intersections
+// TODO: Replace with final rounded rectangle code
+export const segmentIntersectRectangleElement = <
+ Point extends LocalPoint | GlobalPoint,
+ element: ExcalidrawBindableElement,
+ segment: LineSegment<Point>,
+ gap: number = 0,
+): Point[] => {
+ element.x - gap,
+ element.y - gap,
+ element.x + element.width + gap,
+ element.y + element.height + gap,
+ const center = point(
+ (bounds[0] + bounds[2]) / 2,
+ (bounds[1] + bounds[3]) / 2,
+ pointRotateRads(point(bounds[0], bounds[1]), center, element.angle),
+ pointRotateRads(point(bounds[2], bounds[1]), center, element.angle),
+ pointRotateRads(point(bounds[2], bounds[3]), center, element.angle),
+ pointRotateRads(point(bounds[0], bounds[3]), center, element.angle),
+ .map((s) => segmentsIntersectAt(segment, s))
+ .filter((i): i is Point => !!i);
+const distanceToEllipse = <Point extends LocalPoint | GlobalPoint>(
+ ellipse: Ellipse<Point>,
+ const { angle, halfWidth, halfHeight, center } = ellipse;
+ const a = halfWidth;
+ const b = halfHeight;
+ const translatedPoint = vectorAdd(
+ vectorFromPoint(p),
+ vectorScale(vectorFromPoint(center), -1),
+ const [rotatedPointX, rotatedPointY] = pointRotateRads(
+ pointFromVector(translatedPoint),
+ const px = Math.abs(rotatedPointX);
+ const py = Math.abs(rotatedPointY);
+ let tx = 0.707;
+ let ty = 0.707;
+ for (let i = 0; i < 3; i++) {
+ const x = a * tx;
+ const y = b * ty;
+ const ex = ((a * a - b * b) * tx ** 3) / a;
+ const ey = ((b * b - a * a) * ty ** 3) / b;
+ const rx = x - ex;
+ const ry = y - ey;
+ const qx = px - ex;
+ const qy = py - ey;
+ const r = Math.hypot(ry, rx);
+ const q = Math.hypot(qy, qx);
+ tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
+ ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
+ const t = Math.hypot(ty, tx);
+ tx /= t;
+ ty /= t;
+ const [minX, minY] = [
+ a * tx * Math.sign(rotatedPointX),
+ b * ty * Math.sign(rotatedPointY),
+ return pointDistance(point(rotatedPointX, rotatedPointY), point(minX, minY));
+export const pointOnEllipse = <Point extends LocalPoint | GlobalPoint>(
+ return distanceToEllipse(point, ellipse) <= threshold;
+export const pointInEllipse = <Point extends LocalPoint | GlobalPoint>(
+ const { center, angle, halfWidth, halfHeight } = ellipse;
+ (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) +
+ (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <=
+ 1
+export const ellipseAxes = <Point extends LocalPoint | GlobalPoint>(
+ const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight;
+ const majorAxis = widthGreaterThanHeight
+ ? ellipse.halfWidth * 2
+ : ellipse.halfHeight * 2;
+ const minorAxis = widthGreaterThanHeight
+ ? ellipse.halfHeight * 2
+ : ellipse.halfWidth * 2;
+ majorAxis,
+ minorAxis,
+export const ellipseFocusToCenter = <Point extends LocalPoint | GlobalPoint>(
+ const { majorAxis, minorAxis } = ellipseAxes(ellipse);
+ return Math.sqrt(majorAxis ** 2 - minorAxis ** 2);
+export const ellipseExtremes = <Point extends LocalPoint | GlobalPoint>(
+ const { center, angle } = ellipse;
+ const sqSum = majorAxis ** 2 + minorAxis ** 2;
+ const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle);
+ const yMax = Math.sqrt((sqSum - sqDiff) / 2);
+ const xAtYMax =
+ (yMax * sqSum * sin * cos) /
+ (majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2);
+ const xMax = Math.sqrt((sqSum + sqDiff) / 2);
+ const yAtXMax =
+ (xMax * sqSum * sin * cos) /
+ (majorAxis ** 2 * cos ** 2 + minorAxis ** 2 * sin ** 2);
+ const centerVector = vectorFromPoint(center);
+ vectorAdd(vector(xAtYMax, yMax), centerVector),
+ vectorAdd(vectorScale(vector(xAtYMax, yMax), -1), centerVector),
+ vectorAdd(vector(xMax, yAtXMax), centerVector),
@@ -11,16 +11,21 @@ import {
isLinearElement,
} from "../excalidraw/element/typeChecks";
-import { isValueInRange, rotatePoint } from "../excalidraw/math";
import { getElementBounds } from "../excalidraw/element/bounds";
import { arrayToMap } from "../excalidraw/utils";
+import type { LocalPoint } from "../math";
+ rangeIncludesValue,
type Element = NonDeletedExcalidrawElement;
type Elements = readonly NonDeletedExcalidrawElement[];
-type Points = readonly Point[];
+type Points = readonly LocalPoint[];
/** @returns vertices relative to element's top-left [0,0] position */
const getNonLinearElementRelativePoints = (
@@ -28,20 +33,25 @@ const getNonLinearElementRelativePoints = (
Element,
ExcalidrawLinearElement | ExcalidrawFreeDrawElement
>,
-): [TopLeft: Point, TopRight: Point, BottomRight: Point, BottomLeft: Point] => {
+): [
+ TopLeft: LocalPoint,
+ TopRight: LocalPoint,
+ BottomRight: LocalPoint,
+ BottomLeft: LocalPoint,
+] => {
- [element.width / 2, 0],
- [element.width, element.height / 2],
- [element.width / 2, element.height],
- [0, element.height / 2],
+ point(element.width / 2, 0),
+ point(element.width, element.height / 2),
+ point(element.width / 2, element.height),
+ point(0, element.height / 2),
- [0 + element.width, 0],
- [0 + element.width, element.height],
- [0, element.height],
+ point(0 + element.width, 0),
+ point(0 + element.width, element.height),
+ point(0, element.height),
@@ -84,10 +94,10 @@ const getRotatedBBox = (element: Element): Bounds => {
const points = getElementRelativePoints(element);
const { cx, cy } = getMinMaxPoints(points);
- const centerPoint: Point = [cx, cy];
+ const centerPoint = point<LocalPoint>(cx, cy);
- const rotatedPoints = points.map((point) =>
- rotatePoint([point[0], point[1]], centerPoint, element.angle),
+ const rotatedPoints = points.map((p) =>
+ pointRotateRads(p, centerPoint, element.angle),
const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints);
@@ -135,10 +145,16 @@ export const elementPartiallyOverlapsWithOrContainsBBox = (
const elementBBox = getRotatedBBox(element);
- (isValueInRange(elementBBox[0], bbox[0], bbox[2]) ||
- isValueInRange(bbox[0], elementBBox[0], elementBBox[2])) &&
- (isValueInRange(elementBBox[1], bbox[1], bbox[3]) ||
- isValueInRange(bbox[1], elementBBox[1], elementBBox[3]))
+ (rangeIncludesValue(elementBBox[0], rangeInclusive(bbox[0], bbox[2])) ||
+ rangeIncludesValue(
+ bbox[0],
+ rangeInclusive(elementBBox[0], elementBBox[2]),
+ )) &&
+ (rangeIncludesValue(elementBBox[1], rangeInclusive(bbox[1], bbox[3])) ||
+ bbox[1],
+ rangeInclusive(elementBBox[1], elementBBox[3]),
+ ))
@@ -0,0 +1,108 @@
+const fs = require("fs");
+const { build } = require("esbuild");
+const browserConfig = {
+ entryPoints: ["index.ts"],
+ bundle: true,
+ format: "esm",
+// Will be used later for treeshaking
+// function getFiles(dir, files = []) {
+// const fileList = fs.readdirSync(dir);
+// for (const file of fileList) {
+// const name = `${dir}/${file}`;
+// if (
+// name.includes("node_modules") ||
+// name.includes("config") ||
+// name.includes("package.json") ||
+// name.includes("main.js") ||
+// name.includes("index-node.ts") ||
+// name.endsWith(".d.ts") ||
+// name.endsWith(".md")
+// ) {
+// continue;
+// }
+// if (fs.statSync(name).isDirectory()) {
+// getFiles(name, files);
+// } else if (
+// name.match(/\.(sa|sc|c)ss$/) ||
+// name.match(/\.(woff|woff2|eot|ttf|otf)$/) ||
+// name.match(/locales\/[^/]+\.json$/)
+// } else {
+// files.push(name);
+// return files;
+const createESMBrowserBuild = async () => {
+ // Development unminified build with source maps
+ const browserDev = await build({
+ ...browserConfig,
+ outdir: "dist/browser/dev",
+ sourcemap: true,
+ metafile: true,
+ define: {
+ "import.meta.env": JSON.stringify({ DEV: true }),
+ fs.writeFileSync(
+ "meta-browser-dev.json",
+ JSON.stringify(browserDev.metafile),
+ // production minified build without sourcemaps
+ const browserProd = await build({
+ outdir: "dist/browser/prod",
+ minify: true,
+ "import.meta.env": JSON.stringify({ PROD: true }),
+ "meta-browser-prod.json",
+ JSON.stringify(browserProd.metafile),
+const rawConfig = {
+ packages: "external",
+const createESMRawBuild = async () => {
+ const rawDev = await build({
+ ...rawConfig,
+ outdir: "dist/dev",
+ fs.writeFileSync("meta-raw-dev.json", JSON.stringify(rawDev.metafile));
+ const rawProd = await build({
+ outdir: "dist/prod",
+ fs.writeFileSync("meta-raw-prod.json", JSON.stringify(rawProd.metafile));
+createESMRawBuild();
+createESMBrowserBuild();