feat: Remove GA code from binding (#9042)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Márk Tolmács 2025-02-25 22:52:06 +01:00 committed by GitHub
parent 31e8476c78
commit 0ffeaeaecf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 2112 additions and 1832 deletions

View file

@ -12,11 +12,13 @@ import {
TrashIcon,
} from "../../packages/excalidraw/components/icons";
import { STORAGE_KEYS } from "../app_constants";
import type { Curve } from "../../packages/math";
import {
isLineSegment,
type GlobalPoint,
type LineSegment,
} from "../../packages/math";
import { isCurve } from "../../packages/math/curve";
const renderLine = (
context: CanvasRenderingContext2D,
@ -33,6 +35,28 @@ const renderLine = (
context.restore();
};
const renderCubicBezier = (
context: CanvasRenderingContext2D,
zoom: number,
[start, control1, control2, end]: Curve<GlobalPoint>,
color: string,
) => {
context.save();
context.strokeStyle = color;
context.beginPath();
context.moveTo(start[0] * zoom, start[1] * zoom);
context.bezierCurveTo(
control1[0] * zoom,
control1[1] * zoom,
control2[0] * zoom,
control2[1] * zoom,
end[0] * zoom,
end[1] * zoom,
);
context.stroke();
context.restore();
};
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.strokeStyle = "#888";
context.save();
@ -60,6 +84,16 @@ const render = (
el.color,
);
break;
case isCurve(el.data):
renderCubicBezier(
context,
appState.zoom.value,
el.data as Curve<GlobalPoint>,
el.color,
);
break;
default:
throw new Error(`Unknown element type ${JSON.stringify(el)}`);
}
});
};

View file

@ -1633,18 +1633,16 @@ export const actionChangeArrowType = register({
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
startGlobalPoint,
endGlobalPoint,
newElement,
startHoveredElement,
elementsMap,
"start",
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
endGlobalPoint,
startGlobalPoint,
newElement,
endHoveredElement,
elementsMap,
"end",
)
: endGlobalPoint;

View file

@ -188,7 +188,7 @@ export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
export const SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING;
// a small epsilon to make side resizing always take precedence
// (avoids an increase in renders and changes to tests)
const EPSILON = 0.00001;
export const EPSILON = 0.00001;
export const DEFAULT_COLLISION_THRESHOLD =
2 * SIDE_RESIZING_THRESHOLD - EPSILON;

View file

@ -88,9 +88,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
"fixedPoint": null,
"focus": -0.008153707962747813,
"gap": 1,
"focus": -0.007519379844961235,
"gap": 11.562288374879595,
},
"fillStyle": "solid",
"frameId": null,
@ -119,8 +118,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null,
"startBinding": {
"elementId": "id49",
"fixedPoint": null,
"focus": -0.08139534883720931,
"focus": -0.0813953488372095,
"gap": 1,
},
"strokeColor": "#1864ab",
@ -146,9 +144,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
"fixedPoint": null,
"focus": 0.10666666666666667,
"gap": 3.834326468444573,
"gap": 3.8343264684446097,
},
"fillStyle": "solid",
"frameId": null,
@ -177,9 +174,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null,
"startBinding": {
"elementId": "diamond-1",
"fixedPoint": null,
"focus": 0,
"gap": 1,
"gap": 4.545343408287929,
},
"strokeColor": "#e67700",
"strokeStyle": "solid",
@ -338,7 +334,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endArrowhead": "arrow",
"endBinding": {
"elementId": "text-2",
"fixedPoint": null,
"focus": 0,
"gap": 14,
},
@ -369,7 +364,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"startArrowhead": null,
"startBinding": {
"elementId": "text-1",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -442,8 +436,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id42",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
@ -473,7 +466,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"startArrowhead": null,
"startBinding": {
"elementId": "id41",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -620,8 +612,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id46",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
@ -651,7 +642,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"startArrowhead": null,
"startBinding": {
"elementId": "id45",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -1484,8 +1474,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endArrowhead": "arrow",
"endBinding": {
"elementId": "Alice",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 5.299874999999986,
},
"fillStyle": "solid",
@ -1517,7 +1506,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null,
"startBinding": {
"elementId": "Bob",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -1549,9 +1537,8 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endArrowhead": "arrow",
"endBinding": {
"elementId": "B",
"fixedPoint": null,
"focus": 0,
"gap": 1,
"gap": 14,
},
"fillStyle": "solid",
"frameId": null,
@ -1578,7 +1565,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null,
"startBinding": {
"elementId": "Bob",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},

View file

@ -434,7 +434,7 @@ describe("Test Transform", () => {
},
endBinding: {
elementId: ellipse.id,
focus: 0,
focus: -0,
},
});
@ -519,7 +519,7 @@ describe("Test Transform", () => {
},
endBinding: {
elementId: text3.id,
focus: 0,
focus: -0,
},
});
@ -777,8 +777,7 @@ describe("Test Transform", () => {
const [arrow, rect] = excalidrawElements;
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1",
fixedPoint: null,
focus: 0,
focus: -0,
gap: 14,
});
expect(rect.boundElements).toStrictEqual([

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,10 @@
import type {
ElementsMap,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawRectangleElement,
ExcalidrawRectanguloidElement,
} from "./types";
import { getElementBounds } from "./bounds";
import type { FrameNameBounds } from "../types";
@ -16,8 +19,28 @@ import {
isTextElement,
} from "./typeChecks";
import { getBoundTextShape, isPathALoop } from "../shapes";
import type { GlobalPoint, LocalPoint, Polygon } from "../../math";
import { isPointWithinBounds, pointFrom } from "../../math";
import type {
GlobalPoint,
LineSegment,
LocalPoint,
Polygon,
Radians,
} from "../../math";
import {
curveIntersectLineSegment,
isPointWithinBounds,
line,
lineSegment,
lineSegmentIntersectionPoints,
pointFrom,
pointRotateRads,
pointsEqual,
} from "../../math";
import { ellipse, ellipseLineIntersectionPoints } from "../../math/ellipse";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") {
@ -121,3 +144,166 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
): boolean => {
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
};
/**
* Intersect a line with an element for binding test
*
* @param element
* @param line
* @param offset
* @returns
*/
export const intersectElementWithLineSegment = (
element: ExcalidrawElement,
line: LineSegment<GlobalPoint>,
offset: number = 0,
): GlobalPoint[] => {
switch (element.type) {
case "rectangle":
case "image":
case "text":
case "iframe":
case "embeddable":
case "frame":
case "magicframe":
return intersectRectanguloidWithLineSegment(element, line, offset);
case "diamond":
return intersectDiamondWithLineSegment(element, line, offset);
case "ellipse":
return intersectEllipseWithLineSegment(element, line, offset);
default:
throw new Error(`Unimplemented element type '${element.type}'`);
}
};
const intersectRectanguloidWithLineSegment = (
element: ExcalidrawRectanguloidElement,
l: LineSegment<GlobalPoint>,
offset: number = 0,
): GlobalPoint[] => {
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
// To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise.
const rotatedA = pointRotateRads<GlobalPoint>(
l[0],
center,
-element.angle as Radians,
);
const rotatedB = pointRotateRads<GlobalPoint>(
l[1],
center,
-element.angle as Radians,
);
// Get the element's building components we can test against
const [sides, corners] = deconstructRectanguloidElement(element, offset);
return (
[
// Test intersection against the sides, keep only the valid
// intersection points and rotate them back to scene space
...sides
.map((s) =>
lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(rotatedA, rotatedB),
s,
),
)
.filter((x) => x != null)
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle)),
// Test intersection against the corners which are cubic bezier curves,
// keep only the valid intersection points and rotate them back to scene
// space
...corners
.flatMap((t) =>
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
)
.filter((i) => i != null)
.map((j) => pointRotateRads(j, center, element.angle)),
]
// Remove duplicates
.filter(
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
)
);
};
/**
*
* @param element
* @param a
* @param b
* @returns
*/
const intersectDiamondWithLineSegment = (
element: ExcalidrawDiamondElement,
l: LineSegment<GlobalPoint>,
offset: number = 0,
): GlobalPoint[] => {
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
// Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise.
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
const [sides, curves] = deconstructDiamondElement(element, offset);
return (
[
...sides
.map((s) =>
lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(rotatedA, rotatedB),
s,
),
)
.filter((p): p is GlobalPoint => p != null)
// Rotate back intersection points
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle)),
...curves
.flatMap((p) =>
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)),
)
.filter((p) => p != null)
// Rotate back intersection points
.map((p) => pointRotateRads(p, center, element.angle)),
]
// Remove duplicates
.filter(
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
)
);
};
/**
*
* @param element
* @param a
* @param b
* @returns
*/
const intersectEllipseWithLineSegment = (
element: ExcalidrawEllipseElement,
l: LineSegment<GlobalPoint>,
offset: number = 0,
): GlobalPoint[] => {
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
return ellipseLineIntersectionPoints(
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
line(rotatedA, rotatedB),
).map((p) => pointRotateRads(p, center, element.angle));
};

View file

@ -0,0 +1,123 @@
import type { GlobalPoint, Radians } from "../../math";
import {
curvePointDistance,
distanceToLineSegment,
pointFrom,
pointRotateRads,
} from "../../math";
import { ellipse, ellipseDistanceFromPoint } from "../../math/ellipse";
import type {
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawEllipseElement,
ExcalidrawRectanguloidElement,
} from "./types";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
export const distanceToBindableElement = (
element: ExcalidrawBindableElement,
p: GlobalPoint,
): number => {
switch (element.type) {
case "rectangle":
case "image":
case "text":
case "iframe":
case "embeddable":
case "frame":
case "magicframe":
return distanceToRectanguloidElement(element, p);
case "diamond":
return distanceToDiamondElement(element, p);
case "ellipse":
return distanceToEllipseElement(element, p);
}
};
/**
* Returns the distance of a point and the provided rectangular-shaped element,
* accounting for roundness and rotation
*
* @param element The rectanguloid element
* @param p The point to consider
* @returns The eucledian distance to the outline of the rectanguloid element
*/
const distanceToRectanguloidElement = (
element: ExcalidrawRectanguloidElement,
p: GlobalPoint,
) => {
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
// To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
// Get the element's building components we can test against
const [sides, corners] = deconstructRectanguloidElement(element);
return Math.min(
...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
...corners
.map((a) => curvePointDistance(a, rotatedPoint))
.filter((d): d is number => d !== null),
);
};
/**
* Returns the distance of a point and the provided diamond element, accounting
* for roundness and rotation
*
* @param element The diamond element
* @param p The point to consider
* @returns The eucledian distance to the outline of the diamond
*/
const distanceToDiamondElement = (
element: ExcalidrawDiamondElement,
p: GlobalPoint,
): number => {
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
// Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
const [sides, curves] = deconstructDiamondElement(element);
return Math.min(
...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
...curves
.map((a) => curvePointDistance(a, rotatedPoint))
.filter((d): d is number => d !== null),
);
};
/**
* Returns the distance of a point and the provided ellipse element, accounting
* for roundness and rotation
*
* @param element The ellipse element
* @param p The point to consider
* @returns The eucledian distance to the outline of the ellipse
*/
const distanceToEllipseElement = (
element: ExcalidrawEllipseElement,
p: GlobalPoint,
): number => {
const center = pointFrom(
element.x + element.width / 2,
element.y + element.height / 2,
);
return ellipseDistanceFromPoint(
// Instead of rotating the ellipse, rotate the point to the inverse angle
pointRotateRads(p, center, -element.angle as Radians),
ellipse(center, element.width / 2, element.height / 2),
);
};

View file

@ -18,6 +18,7 @@ import type {
import { ARROW_TYPE } from "../constants";
import type { LocalPoint } from "../../math";
import { pointFrom } from "../../math";
import "../../utils/test-utils";
const { h } = window;

View file

@ -19,8 +19,6 @@ import { invariant, isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
import type { AppState } from "../types";
import {
bindPointToSnapToElementOutline,
distanceToBindableElement,
avoidRectangularCorner,
FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement,
@ -42,7 +40,7 @@ import {
headingForPoint,
} from "./heading";
import { type ElementUpdate } from "./mutateElement";
import { isBindableElement, isRectanguloidElement } from "./typeChecks";
import { isBindableElement } from "./typeChecks";
import {
type ExcalidrawElbowArrowElement,
type NonDeletedSceneElementsMap,
@ -55,6 +53,7 @@ import type {
FixedPointBinding,
FixedSegment,
} from "./types";
import { distanceToBindableElement } from "./distance";
type GridAddress = [number, number] & { _brand: "gridaddress" };
@ -1177,19 +1176,27 @@ const getElbowArrowData = (
)
: [startElement, endElement];
const startGlobalPoint = getGlobalPoint(
{
...arrow,
elbowed: true,
points: nextPoints,
} as ExcalidrawElbowArrowElement,
"start",
arrow.startBinding?.fixedPoint,
origStartGlobalPoint,
origEndGlobalPoint,
elementsMap,
startElement,
hoveredStartElement,
options?.isDragging,
);
const endGlobalPoint = getGlobalPoint(
{
...arrow,
elbowed: true,
points: nextPoints,
} as ExcalidrawElbowArrowElement,
"end",
arrow.endBinding?.fixedPoint,
origEndGlobalPoint,
origStartGlobalPoint,
elementsMap,
endElement,
hoveredEndElement,
options?.isDragging,
@ -2133,21 +2140,20 @@ const neighborIndexToHeading = (idx: number): Heading => {
};
const getGlobalPoint = (
arrow: ExcalidrawElbowArrowElement,
startOrEnd: "start" | "end",
fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint,
otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
boundElement?: ExcalidrawBindableElement | null,
hoveredElement?: ExcalidrawBindableElement | null,
isDragging?: boolean,
): GlobalPoint => {
if (isDragging) {
if (hoveredElement) {
const snapPoint = getSnapPoint(
initialPoint,
otherPoint,
const snapPoint = bindPointToSnapToElementOutline(
arrow,
hoveredElement,
elementsMap,
startOrEnd,
);
return snapToMid(hoveredElement, snapPoint);
@ -2164,29 +2170,16 @@ const getGlobalPoint = (
// NOTE: Resize scales the binding position point too, so we need to update it
return Math.abs(
distanceToBindableElement(boundElement, fixedGlobalPoint, elementsMap) -
distanceToBindableElement(boundElement, fixedGlobalPoint) -
FIXED_BINDING_DISTANCE,
) > 0.01
? getSnapPoint(initialPoint, otherPoint, boundElement, elementsMap)
? bindPointToSnapToElementOutline(arrow, boundElement, startOrEnd)
: fixedGlobalPoint;
}
return initialPoint;
};
const getSnapPoint = (
p: GlobalPoint,
otherPoint: GlobalPoint,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
) =>
bindPointToSnapToElementOutline(
isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p,
otherPoint,
element,
elementsMap,
);
const getBindPointHeading = (
p: GlobalPoint,
otherPoint: GlobalPoint,
@ -2201,9 +2194,12 @@ const getBindPointHeading = (
hoveredElement &&
aabbForElement(
hoveredElement,
Array(4).fill(
distanceToBindableElement(hoveredElement, p, elementsMap),
) as [number, number, number, number],
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [
number,
number,
number,
number,
],
),
elementsMap,
origPoint,

View file

@ -0,0 +1,355 @@
import { getDiamondPoints } from ".";
import type { Curve, LineSegment } from "../../math";
import {
curve,
lineSegment,
pointFrom,
pointFromVector,
rectangle,
vectorFromPoint,
vectorNormalize,
vectorScale,
type GlobalPoint,
} from "../../math";
import { getCornerRadius } from "../shapes";
import type {
ExcalidrawDiamondElement,
ExcalidrawRectanguloidElement,
} from "./types";
/**
* Get the building components of a rectanguloid element in the form of
* line segments and curves.
*
* @param element Target rectanguloid element
* @param offset Optional offset to expand the rectanguloid shape
* @returns Tuple of line segments (0) and curves (1)
*/
export function deconstructRectanguloidElement(
element: ExcalidrawRectanguloidElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const roundness = getCornerRadius(
Math.min(element.width, element.height),
element,
);
if (roundness <= 0) {
const r = rectangle(
pointFrom(element.x - offset, element.y - offset),
pointFrom(
element.x + element.width + offset,
element.y + element.height + offset,
),
);
const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
);
const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
);
const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
);
const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
);
const sides = [top, right, bottom, left];
return [sides, []];
}
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
const r = rectangle(
pointFrom(element.x, element.y),
pointFrom(element.x + element.width, element.y + element.height),
);
const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
);
const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
);
const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
);
const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
);
const offsets = [
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
),
offset,
), // TOP LEFT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
),
offset,
), //TOP RIGHT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
),
offset,
), // BOTTOM RIGHT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
),
offset,
), // BOTTOM LEFT
];
const corners = [
curve(
pointFromVector(offsets[0], left[1]),
pointFromVector(
offsets[0],
pointFrom<GlobalPoint>(
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
),
),
pointFromVector(
offsets[0],
pointFrom<GlobalPoint>(
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
),
),
pointFromVector(offsets[0], top[0]),
), // TOP LEFT
curve(
pointFromVector(offsets[1], top[1]),
pointFromVector(
offsets[1],
pointFrom<GlobalPoint>(
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
),
),
pointFromVector(
offsets[1],
pointFrom<GlobalPoint>(
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
),
),
pointFromVector(offsets[1], right[0]),
), // TOP RIGHT
curve(
pointFromVector(offsets[2], right[1]),
pointFromVector(
offsets[2],
pointFrom<GlobalPoint>(
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
),
),
pointFromVector(
offsets[2],
pointFrom<GlobalPoint>(
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
),
),
pointFromVector(offsets[2], bottom[1]),
), // BOTTOM RIGHT
curve(
pointFromVector(offsets[3], bottom[0]),
pointFromVector(
offsets[3],
pointFrom<GlobalPoint>(
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
),
),
pointFromVector(
offsets[3],
pointFrom<GlobalPoint>(
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
),
),
pointFromVector(offsets[3], left[0]),
), // BOTTOM LEFT
];
const sides = [
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
];
return [sides, corners];
}
/**
* Get the building components of a diamond element in the form of
* line segments and curves as a tuple, in this order.
*
* @param element The element to deconstruct
* @param offset An optional offset
* @returns Tuple of line segments (0) and curves (1)
*/
export function deconstructDiamondElement(
element: ExcalidrawDiamondElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
if (element.roundness?.type == null) {
const [top, right, bottom, left]: GlobalPoint[] = [
pointFrom(element.x + topX, element.y + topY - offset),
pointFrom(element.x + rightX + offset, element.y + rightY),
pointFrom(element.x + bottomX, element.y + bottomY + offset),
pointFrom(element.x + leftX - offset, element.y + leftY),
];
// Create the line segment parts of the diamond
// NOTE: Horizontal and vertical seems to be flipped here
const topRight = lineSegment<GlobalPoint>(
pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius),
pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius),
);
const bottomRight = lineSegment<GlobalPoint>(
pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius),
pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius),
);
const bottomLeft = lineSegment<GlobalPoint>(
pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius),
pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius),
);
const topLeft = lineSegment<GlobalPoint>(
pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius),
pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius),
);
return [[topRight, bottomRight, bottomLeft, topLeft], []];
}
const center = pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
const [top, right, bottom, left]: GlobalPoint[] = [
pointFrom(element.x + topX, element.y + topY),
pointFrom(element.x + rightX, element.y + rightY),
pointFrom(element.x + bottomX, element.y + bottomY),
pointFrom(element.x + leftX, element.y + leftY),
];
const offsets = [
vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT
vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM
vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT
vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP
];
const corners = [
curve(
pointFromVector(
offsets[0],
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
right[1] - horizontalRadius,
),
),
pointFromVector(offsets[0], right),
pointFromVector(offsets[0], right),
pointFromVector(
offsets[0],
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
right[1] + horizontalRadius,
),
),
), // RIGHT
curve(
pointFromVector(
offsets[1],
pointFrom<GlobalPoint>(
bottom[0] + verticalRadius,
bottom[1] - horizontalRadius,
),
),
pointFromVector(offsets[1], bottom),
pointFromVector(offsets[1], bottom),
pointFromVector(
offsets[1],
pointFrom<GlobalPoint>(
bottom[0] - verticalRadius,
bottom[1] - horizontalRadius,
),
),
), // BOTTOM
curve(
pointFromVector(
offsets[2],
pointFrom<GlobalPoint>(
left[0] + verticalRadius,
left[1] + horizontalRadius,
),
),
pointFromVector(offsets[2], left),
pointFromVector(offsets[2], left),
pointFromVector(
offsets[2],
pointFrom<GlobalPoint>(
left[0] + verticalRadius,
left[1] - horizontalRadius,
),
),
), // LEFT
curve(
pointFromVector(
offsets[3],
pointFrom<GlobalPoint>(
top[0] - verticalRadius,
top[1] + horizontalRadius,
),
),
pointFromVector(offsets[3], top),
pointFromVector(offsets[3], top),
pointFromVector(
offsets[3],
pointFrom<GlobalPoint>(
top[0] + verticalRadius,
top[1] + horizontalRadius,
),
),
), // TOP
];
const sides = [
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
];
return [sides, corners];
}

View file

@ -197,7 +197,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 99,
"height": "102.35417",
"id": "id172",
"index": "a2",
"isDeleted": false,
@ -211,8 +211,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"98.20800",
99,
"101.77517",
"102.35417",
],
],
"roughness": 1,
@ -227,8 +227,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 40,
"width": "98.20800",
"x": 1,
"width": "101.77517",
"x": "0.70711",
"y": 0,
}
`;
@ -294,24 +294,22 @@ History {
"deleted": {
"endBinding": {
"elementId": "id171",
"fixedPoint": null,
"focus": "0.00990",
"gap": 1,
},
"height": "0.98017",
"height": "0.98597",
"points": [
[
0,
0,
],
[
98,
"-0.98017",
"98.58579",
"-0.98597",
],
],
"startBinding": {
"elementId": "id170",
"fixedPoint": null,
"focus": "0.02970",
"gap": 1,
},
@ -319,24 +317,22 @@ History {
"inserted": {
"endBinding": {
"elementId": "id171",
"fixedPoint": null,
"focus": "-0.02000",
"gap": 1,
},
"height": "0.00169",
"height": "0.00119",
"points": [
[
0,
0,
],
[
98,
"0.00169",
"98.58579",
"0.00119",
],
],
"startBinding": {
"elementId": "id170",
"fixedPoint": null,
"focus": "0.02000",
"gap": 1,
},
@ -393,15 +389,15 @@ History {
"focus": 0,
"gap": 1,
},
"height": 99,
"height": "102.35417",
"points": [
[
0,
0,
],
[
"98.20800",
99,
"101.77517",
"102.35417",
],
],
"startBinding": null,
@ -410,28 +406,26 @@ History {
"inserted": {
"endBinding": {
"elementId": "id171",
"fixedPoint": null,
"focus": "0.00990",
"gap": 1,
},
"height": "0.98161",
"height": "0.98700",
"points": [
[
0,
0,
],
[
98,
"-0.98161",
"98.58579",
"-0.98700",
],
],
"startBinding": {
"elementId": "id170",
"fixedPoint": null,
"focus": "0.02970",
"gap": 1,
},
"y": "0.99245",
"y": "0.99465",
},
},
"id175" => Delta {
@ -824,7 +818,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 30,
"width": 0,
"width": 50,
"x": 200,
"y": 0,
}
@ -858,7 +852,7 @@ History {
0,
],
[
0,
50,
0,
],
],
@ -934,8 +928,7 @@ History {
"inserted": {
"endBinding": {
"elementId": "id166",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"points": [
@ -944,13 +937,12 @@ History {
0,
],
[
0,
50,
0,
],
],
"startBinding": {
"elementId": "id165",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -1246,7 +1238,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "2.61991",
"height": "2.52823",
"id": "id178",
"index": "Zz",
"isDeleted": false,
@ -1260,8 +1252,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"98.00000",
"-2.61991",
"98.58579",
"-2.52823",
],
],
"roughness": 1,
@ -1284,9 +1276,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 11,
"width": "98.00000",
"x": "1.00000",
"y": "3.98333",
"width": "98.58579",
"x": "0.70711",
"y": "3.82861",
}
`;
@ -1617,7 +1609,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "2.61991",
"height": "2.52823",
"id": "id181",
"index": "a0",
"isDeleted": false,
@ -1631,8 +1623,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"98.00000",
"-2.61991",
"98.58579",
"-2.52823",
],
],
"roughness": 1,
@ -1655,9 +1647,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 11,
"width": "98.00000",
"x": "1.00000",
"y": "3.98333",
"width": "98.58579",
"x": "0.70711",
"y": "3.82861",
}
`;
@ -1775,7 +1767,7 @@ History {
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "22.36242",
"height": "22.07000",
"index": "a0",
"isDeleted": false,
"lastCommittedPoint": null,
@ -1788,8 +1780,8 @@ History {
0,
],
[
"98.00000",
"-22.36242",
"99.27949",
"-22.07000",
],
],
"roughness": 1,
@ -1810,9 +1802,9 @@ History {
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"width": "98.00000",
"x": 1,
"y": 34,
"width": "99.27949",
"x": "0.01341",
"y": "33.34227",
},
"inserted": {
"isDeleted": true,
@ -2322,14 +2314,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id185",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "408.19672",
"height": "410.63965",
"id": "id186",
"index": "a2",
"isDeleted": false,
@ -2343,8 +2334,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
498,
"-408.19672",
"501.24760",
"-410.63965",
],
],
"roughness": 1,
@ -2354,7 +2345,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"startArrowhead": null,
"startBinding": {
"elementId": "id184",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -2364,8 +2354,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 10,
"width": 498,
"x": 1,
"width": "501.24760",
"x": "0.70711",
"y": 0,
}
`;
@ -2484,8 +2474,7 @@ History {
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id185",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
@ -2515,7 +2504,6 @@ History {
"startArrowhead": null,
"startBinding": {
"elementId": "id184",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -15122,8 +15110,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id58",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
@ -15143,7 +15130,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0,
],
[
"98.00000",
"98.58579",
0,
],
],
@ -15154,7 +15141,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startArrowhead": null,
"startBinding": {
"elementId": "id56",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -15164,8 +15150,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow",
"updated": 1,
"version": 10,
"width": "98.00000",
"x": 1,
"width": "98.58579",
"x": "0.70711",
"y": 0,
}
`;
@ -15493,8 +15479,7 @@ History {
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id58",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
@ -15524,7 +15509,6 @@ History {
"startArrowhead": null,
"startBinding": {
"elementId": "id56",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -15821,8 +15805,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id52",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
@ -15842,7 +15825,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0,
],
[
"98.00000",
"98.58579",
0,
],
],
@ -15853,7 +15836,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startArrowhead": null,
"startBinding": {
"elementId": "id50",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -15863,8 +15845,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow",
"updated": 1,
"version": 10,
"width": "98.00000",
"x": 1,
"width": "98.58579",
"x": "0.70711",
"y": 0,
}
`;
@ -16116,8 +16098,7 @@ History {
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id52",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
@ -16147,7 +16128,6 @@ History {
"startArrowhead": null,
"startBinding": {
"elementId": "id50",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -16444,8 +16424,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id64",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
@ -16465,7 +16444,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0,
],
[
"98.00000",
"98.58579",
0,
],
],
@ -16476,7 +16455,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startArrowhead": null,
"startBinding": {
"elementId": "id62",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -16486,8 +16464,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow",
"updated": 1,
"version": 10,
"width": "98.00000",
"x": 1,
"width": "98.58579",
"x": "0.70711",
"y": 0,
}
`;
@ -16739,8 +16717,7 @@ History {
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id64",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
@ -16770,7 +16747,6 @@ History {
"startArrowhead": null,
"startBinding": {
"elementId": "id62",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -17065,8 +17041,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id70",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
@ -17086,7 +17061,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0,
],
[
"98.00000",
"98.58579",
0,
],
],
@ -17097,7 +17072,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startArrowhead": null,
"startBinding": {
"elementId": "id68",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -17107,8 +17081,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow",
"updated": 1,
"version": 10,
"width": "98.00000",
"x": 1,
"width": "98.58579",
"x": "0.70711",
"y": 0,
}
`;
@ -17170,7 +17144,6 @@ History {
],
"startBinding": {
"elementId": "id68",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -17431,8 +17404,7 @@ History {
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id70",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
@ -17462,7 +17434,6 @@ History {
"startArrowhead": null,
"startBinding": {
"elementId": "id68",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -17783,8 +17754,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id77",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
@ -17804,7 +17774,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0,
],
[
"98.00000",
"98.58579",
0,
],
],
@ -17815,7 +17785,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"startArrowhead": null,
"startBinding": {
"elementId": "id75",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -17825,8 +17794,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"type": "arrow",
"updated": 1,
"version": 11,
"width": "98.00000",
"x": 1,
"width": "98.58579",
"x": "0.70711",
"y": 0,
}
`;
@ -17887,8 +17856,7 @@ History {
"deleted": {
"endBinding": {
"elementId": "id77",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"points": [
@ -17903,7 +17871,6 @@ History {
],
"startBinding": {
"elementId": "id75",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@ -18165,8 +18132,7 @@ History {
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id77",
"fixedPoint": null,
"focus": 0,
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
@ -18196,7 +18162,6 @@ History {
"startArrowhead": null,
"startBinding": {
"elementId": "id75",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},

View file

@ -190,14 +190,13 @@ exports[`move element > rectangles with binding arrow 7`] = `
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id1",
"fixedPoint": null,
"focus": "-0.46667",
"gap": 10,
},
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "81.47368",
"height": "84.41974",
"id": "id2",
"index": "a2",
"isDeleted": false,
@ -211,8 +210,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
0,
],
[
81,
"81.47368",
"83.92893",
"84.41974",
],
],
"roughness": 1,
@ -223,7 +222,6 @@ exports[`move element > rectangles with binding arrow 7`] = `
"startArrowhead": null,
"startBinding": {
"elementId": "id0",
"fixedPoint": null,
"focus": "-0.60000",
"gap": 10,
},
@ -234,7 +232,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"updated": 1,
"version": 11,
"versionNonce": 1051383431,
"width": 81,
"width": "83.92893",
"x": 110,
"y": 50,
}

View file

@ -64,7 +64,6 @@ describe("element binding", () => {
expect(arrow.startBinding).toEqual({
elementId: rect.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});
@ -77,13 +76,11 @@ describe("element binding", () => {
// Both the start and the end points should be bound
expect(arrow.startBinding).toEqual({
elementId: rect.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});
expect(arrow.endBinding).toEqual({
elementId: rect.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});

View file

@ -9,6 +9,7 @@ import {
togglePopover,
getCloneByOrigId,
} from "./test-utils";
import "../../utils/test-utils";
import { Excalidraw } from "../index";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { API } from "./helpers/api";
@ -1321,13 +1322,11 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(5);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});
expect(arrow.endBinding).toEqual({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});
@ -1346,13 +1345,11 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(1);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});
expect(arrow.endBinding).toEqual({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});
@ -1371,13 +1368,11 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});
expect(arrow.endBinding).toEqual({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});
@ -1404,13 +1399,11 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});
expect(arrow.endBinding).toEqual({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});
@ -1429,13 +1422,11 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(1);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});
expect(arrow.endBinding).toEqual({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
});
@ -1486,13 +1477,11 @@ describe("history", () => {
id: arrow.id,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
@ -1533,13 +1522,11 @@ describe("history", () => {
id: arrow.id,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
@ -1614,13 +1601,11 @@ describe("history", () => {
id: arrow.id,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
@ -1689,13 +1674,11 @@ describe("history", () => {
id: arrow.id,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
@ -4276,13 +4259,11 @@ describe("history", () => {
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
@ -4347,13 +4328,11 @@ describe("history", () => {
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
@ -4414,13 +4393,11 @@ describe("history", () => {
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
@ -4489,14 +4466,12 @@ describe("history", () => {
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
// rebound with previous rectangle
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
@ -4788,14 +4763,12 @@ describe("history", () => {
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: null,
focus: 0,
gap: 1,
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: null,
focus: 0,
focus: -0,
gap: 1,
}),
isDeleted: true,
@ -4838,13 +4811,11 @@ describe("history", () => {
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: null,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}),

View file

@ -1238,7 +1238,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0);
mouse.upAt(200, 0);
expect(arrow.width).toBe(200);
expect(arrow.width).toBeCloseTo(204, 0);
expect(rect.x).toBe(200);
expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(

View file

@ -123,7 +123,7 @@ describe("move element", () => {
expect([rectB.x, rectB.y]).toEqual([201, 2]);
expect([Math.round(arrow.x), Math.round(arrow.y)]).toEqual([110, 50]);
expect([Math.round(arrow.width), Math.round(arrow.height)]).toEqual([
81, 81,
84, 84,
]);
h.elements.forEach((element) => expect(element).toMatchSnapshot());

View file

@ -181,12 +181,12 @@ describe("generic element", () => {
UI.resize(rectangle, "e", [40, 0]);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
UI.resize(rectangle, "w", [50, 0]);
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0);
});
it("resizes with a label", async () => {
@ -501,12 +501,12 @@ describe("arrow element", () => {
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
});
@ -529,13 +529,13 @@ describe("arrow element", () => {
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.13);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.11);
});
});
@ -811,15 +811,16 @@ describe("image element", () => {
UI.resize(image, "ne", [40, 0]);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(31, 0);
const imageWidth = image.width;
const scale = 20 / image.height;
UI.resize(image, "nw", [50, 20]);
expect(arrow.endBinding?.elementId).toEqual(image.id);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(
expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo(
30 + imageWidth * scale,
0,
);
});
});
@ -1024,7 +1025,7 @@ describe("multiple selection", () => {
expect(leftBoundArrow.x).toBeCloseTo(-110);
expect(leftBoundArrow.y).toBeCloseTo(50);
expect(leftBoundArrow.width).toBeCloseTo(140, 0);
expect(leftBoundArrow.width).toBeCloseTo(143, 0);
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
expect(leftBoundArrow.angle).toEqual(0);
expect(leftBoundArrow.startBinding).toBeNull();
@ -1046,7 +1047,9 @@ describe("multiple selection", () => {
expect(rightBoundArrow.endBinding?.elementId).toBe(
rightArrowBinding.elementId,
);
expect(rightBoundArrow.endBinding?.focus).toBe(rightArrowBinding.focus);
expect(rightBoundArrow.endBinding?.focus).toBeCloseTo(
rightArrowBinding.focus!,
);
});
it("resizes with labeled arrows", async () => {

View file

@ -32,7 +32,7 @@ test("unselected bound arrow updates when rotating its target element", async ()
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
expect(arrow.x).toBeCloseTo(-80);
expect(arrow.y).toBeCloseTo(50);
expect(arrow.width).toBeCloseTo(110.7, 1);
expect(arrow.width).toBeCloseTo(116.7, 1);
expect(arrow.height).toBeCloseTo(0);
});
@ -69,8 +69,8 @@ test("unselected bound arrows update when rotating their target elements", async
expect(ellipseArrow.x).toEqual(0);
expect(ellipseArrow.y).toEqual(0);
expect(ellipseArrow.points[0]).toEqual([0, 0]);
expect(ellipseArrow.points[1][0]).toBeCloseTo(48.5, 1);
expect(ellipseArrow.points[1][1]).toBeCloseTo(126.5, 1);
expect(ellipseArrow.points[1][0]).toBeCloseTo(48.98, 1);
expect(ellipseArrow.points[1][1]).toBeCloseTo(125.79, 1);
expect(textArrow.endBinding?.elementId).toEqual(text.id);
expect(textArrow.x).toEqual(360);

View file

@ -16,7 +16,6 @@ import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants";
import { getSelectedElements } from "../scene/selection";
import type { ExcalidrawElement } from "../element/types";
import { UI } from "./helpers/ui";
import { diffStringsUnified } from "jest-diff";
import ansi from "ansicolor";
import { ORIG_ID } from "../constants";
import { arrayToMap } from "../utils";
@ -259,36 +258,6 @@ expect.extend({
pass: false,
};
},
toCloselyEqualPoints(received, expected, precision) {
if (!Array.isArray(received) || !Array.isArray(expected)) {
throw new Error("expected and received are not point arrays");
}
const COMPARE = 1 / Math.pow(10, precision || 2);
const pass = received.every(
(point, idx) =>
Math.abs(expected[idx]?.[0] - point[0]) < COMPARE &&
Math.abs(expected[idx]?.[1] - point[1]) < COMPARE,
);
if (!pass) {
return {
message: () => ` The provided array of points are not close enough.
${diffStringsUnified(
JSON.stringify(expected, undefined, 2),
JSON.stringify(received, undefined, 2),
)}`,
pass: false,
};
}
return {
message: () => `expected ${received} to not be close to ${expected}`,
pass: true,
};
},
});
/**

View file

@ -1,3 +1,4 @@
import type { Curve } from "../math";
import {
isLineSegment,
lineSegment,
@ -6,7 +7,7 @@ import {
type LocalPoint,
} from "../math";
import type { LineSegment } from "../utils";
import type { BoundingBox, Bounds } from "./element/bounds";
import type { Bounds } from "./element/bounds";
import { isBounds } from "./element/typeChecks";
// The global data holder to collect the debug operations
@ -16,17 +17,29 @@ declare global {
data: DebugElement[][];
currentFrame?: number;
};
debugDrawPoint: typeof debugDrawPoint;
debugDrawLine: typeof debugDrawLine;
}
}
export type DebugElement = {
color: string;
data: LineSegment<GlobalPoint>;
data: LineSegment<GlobalPoint> | Curve<GlobalPoint>;
permanent: boolean;
};
export const debugDrawCubicBezier = (
c: Curve<GlobalPoint>,
opts?: {
color?: string;
permanent?: boolean;
},
) => {
addToCurrentFrame({
color: opts?.color ?? "purple",
permanent: !!opts?.permanent,
data: c,
});
};
export const debugDrawLine = (
segment: LineSegment<GlobalPoint> | LineSegment<GlobalPoint>[],
opts?: {
@ -80,41 +93,6 @@ export const debugDrawPoint = (
);
};
export const debugDrawBoundingBox = (
box: BoundingBox | BoundingBox[],
opts?: {
color?: string;
permanent?: boolean;
},
) => {
(Array.isArray(box) ? box : [box]).forEach((bbox) =>
debugDrawLine(
[
lineSegment(
pointFrom<GlobalPoint>(bbox.minX, bbox.minY),
pointFrom<GlobalPoint>(bbox.maxX, bbox.minY),
),
lineSegment(
pointFrom<GlobalPoint>(bbox.maxX, bbox.minY),
pointFrom<GlobalPoint>(bbox.maxX, bbox.maxY),
),
lineSegment(
pointFrom<GlobalPoint>(bbox.maxX, bbox.maxY),
pointFrom<GlobalPoint>(bbox.minX, bbox.maxY),
),
lineSegment(
pointFrom<GlobalPoint>(bbox.minX, bbox.maxY),
pointFrom<GlobalPoint>(bbox.minX, bbox.minY),
),
],
{
color: opts?.color ?? "cyan",
permanent: opts?.permanent,
},
),
);
};
export const debugDrawBounds = (
box: Bounds | Bounds[],
opts?: {

View file

@ -26,7 +26,10 @@ export const normalizeRadians = (angle: Radians): Radians => {
export const cartesian2Polar = <P extends GlobalPoint | LocalPoint>([
x,
y,
]: P): PolarCoords => [Math.hypot(x, y), Math.atan2(y, x)];
]: P): PolarCoords => [
Math.hypot(x, y),
normalizeRadians(Math.atan2(y, x) as Radians),
];
export function degreesToRadians(degrees: Degrees): Radians {
return ((degrees * Math.PI) / 180) as Radians;

View file

@ -1,41 +0,0 @@
import { isPointOnSymmetricArc } from "./arc";
import { pointFrom } 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,
},
pointFrom(0.92291667, 0.385),
),
).toBe(true);
});
it("should not detect point outside of a simple arc", () => {
expect(
isPointOnSymmetricArc(
{
radius: 1,
startAngle: -Math.PI / 4,
endAngle: Math.PI / 4,
},
pointFrom(-0.92291667, 0.385),
),
).toBe(false);
});
it("should not detect point with good angle but incorrect radius", () => {
expect(
isPointOnSymmetricArc(
{
radius: 1,
startAngle: -Math.PI / 4,
endAngle: Math.PI / 4,
},
pointFrom(-0.5, 0.5),
),
).toBe(false);
});
});

View file

@ -1,20 +0,0 @@
import { cartesian2Polar } from "./angle";
import type { GlobalPoint, LocalPoint, SymmetricArc } from "./types";
import { PRECISION } from "./utils";
/**
* 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,
): boolean => {
const [radius, angle] = cartesian2Polar(point);
return startAngle < endAngle
? Math.abs(radius - arcRadius) < PRECISION &&
startAngle <= angle &&
endAngle >= angle
: startAngle <= angle || endAngle >= angle;
};

101
packages/math/curve.test.ts Normal file
View file

@ -0,0 +1,101 @@
import "../utils/test-utils";
import {
curve,
curveClosestPoint,
curveIntersectLineSegment,
curvePointDistance,
} from "./curve";
import { pointFrom } from "./point";
import { lineSegment } from "./segment";
describe("Math curve", () => {
describe("line segment intersection", () => {
it("point is found when control points are the same", () => {
const c = curve(
pointFrom(100, 0),
pointFrom(100, 100),
pointFrom(100, 100),
pointFrom(0, 100),
);
const l = lineSegment(pointFrom(0, 0), pointFrom(200, 200));
expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([
[87.5, 87.5],
]);
});
it("point is found when control points aren't the same", () => {
const c = curve(
pointFrom(100, 0),
pointFrom(100, 60),
pointFrom(60, 100),
pointFrom(0, 100),
);
const l = lineSegment(pointFrom(0, 0), pointFrom(200, 200));
expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([
[72.5, 72.5],
]);
});
it("points are found when curve is sliced at 3 points", () => {
const c = curve(
pointFrom(-50, -50),
pointFrom(10, -50),
pointFrom(10, 50),
pointFrom(50, 50),
);
const l = lineSegment(pointFrom(0, 112.5), pointFrom(90, 0));
expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([[50, 50]]);
});
it("can be detected where the determinant is overly precise", () => {
const c = curve(
pointFrom(41.028864759926016, 12.226249068355052),
pointFrom(41.028864759926016, 33.55958240168839),
pointFrom(30.362198093259348, 44.22624906835505),
pointFrom(9.028864759926016, 44.22624906835505),
);
const l = lineSegment(
pointFrom(-82.30963544324186, -41.19949363038283),
pointFrom(188.2149592542487, 134.75505940984908),
);
expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([
[34.4, 34.71],
]);
});
});
describe("point closest to other", () => {
it("point can be found", () => {
const c = curve(
pointFrom(-50, -50),
pointFrom(10, -50),
pointFrom(10, 50),
pointFrom(50, 50),
);
const p = pointFrom(0, 0);
expect([curveClosestPoint(c, p)]).toCloselyEqualPoints([
[5.965462100367372, -3.04104878946646],
]);
});
});
describe("point shortest distance", () => {
it("can be determined", () => {
const c = curve(
pointFrom(-50, -50),
pointFrom(10, -50),
pointFrom(10, 50),
pointFrom(50, 50),
);
const p = pointFrom(0, 0);
expect(curvePointDistance(c, p)).toBeCloseTo(6.695873043213627);
});
});
});

View file

@ -1,5 +1,7 @@
import { pointFrom, pointRotateRads } from "./point";
import type { Curve, GlobalPoint, LocalPoint, Radians } from "./types";
import type { Bounds } from "../excalidraw/element/bounds";
import { isPoint, pointDistance, pointFrom } from "./point";
import { rectangle, rectangleIntersectLineSegment } from "./rectangle";
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
/**
*
@ -18,206 +20,263 @@ export function curve<Point extends GlobalPoint | LocalPoint>(
return [a, b, c, d] as Curve<Point>;
}
export const curveRotate = <Point extends LocalPoint | GlobalPoint>(
curve: Curve<Point>,
angle: Radians,
origin: Point,
) => {
return curve.map((p) => pointRotateRads(p, origin, angle));
};
function gradient(
f: (t: number, s: number) => number,
t0: number,
s0: number,
delta: number = 1e-6,
): number[] {
return [
(f(t0 + delta, s0) - f(t0 - delta, s0)) / (2 * delta),
(f(t0, s0 + delta) - f(t0, s0 - delta)) / (2 * delta),
];
}
function solve(
f: (t: number, s: number) => [number, number],
t0: number,
s0: number,
tolerance: number = 1e-3,
iterLimit: number = 10,
): number[] | null {
let error = Infinity;
let iter = 0;
while (error >= tolerance) {
if (iter >= iterLimit) {
return null;
}
const y0 = f(t0, s0);
const jacobian = [
gradient((t, s) => f(t, s)[0], t0, s0),
gradient((t, s) => f(t, s)[1], t0, s0),
];
const b = [[-y0[0]], [-y0[1]]];
const det =
jacobian[0][0] * jacobian[1][1] - jacobian[0][1] * jacobian[1][0];
if (det === 0) {
return null;
}
const iJ = [
[jacobian[1][1] / det, -jacobian[0][1] / det],
[-jacobian[1][0] / det, jacobian[0][0] / det],
];
const h = [
[iJ[0][0] * b[0][0] + iJ[0][1] * b[1][0]],
[iJ[1][0] * b[0][0] + iJ[1][1] * b[1][0]],
];
t0 = t0 + h[0][0];
s0 = s0 + h[1][0];
const [tErr, sErr] = f(t0, s0);
error = Math.max(Math.abs(tErr), Math.abs(sErr));
iter += 1;
}
return [t0, s0];
}
const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>,
t: number,
) =>
pointFrom<Point>(
(1 - t) ** 3 * c[0][0] +
3 * (1 - t) ** 2 * t * c[1][0] +
3 * (1 - t) * t ** 2 * c[2][0] +
t ** 3 * c[3][0],
(1 - t) ** 3 * c[0][1] +
3 * (1 - t) ** 2 * t * c[1][1] +
3 * (1 - t) * t ** 2 * c[2][1] +
t ** 3 * c[3][1],
);
/**
*
* @param pointsIn
* @param curveTightness
* @returns
* Computes the intersection between a cubic spline and a line segment.
*/
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.");
export function curveIntersectLineSegment<
Point extends GlobalPoint | LocalPoint,
>(c: Curve<Point>, l: LineSegment<Point>): Point[] {
// Optimize by doing a cheap bounding box check first
const bounds = curveBounds(c);
if (
rectangleIntersectLineSegment(
rectangle(
pointFrom(bounds[0], bounds[1]),
pointFrom(bounds[2], bounds[3]),
),
l,
).length === 0
) {
return [];
}
const out: Point[] = [];
if (len === 3) {
out.push(
pointFrom(pointsIn[0][0], pointsIn[0][1]), // Points need to be cloned
pointFrom(pointsIn[1][0], pointsIn[1][1]), // Points need to be cloned
pointFrom(pointsIn[2][0], pointsIn[2][1]), // Points need to be cloned
pointFrom(pointsIn[2][0], pointsIn[2][1]), // Points need to be cloned
const line = (s: number) =>
pointFrom<Point>(
l[0][0] + s * (l[1][0] - l[0][0]),
l[0][1] + s * (l[1][1] - l[0][1]),
);
} else {
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) {
points.push(pointsIn[i]);
}
const initial_guesses: [number, number][] = [
[0.5, 0],
[0.2, 0],
[0.8, 0],
];
const calculate = ([t0, s0]: [number, number]) => {
const solution = solve(
(t: number, s: number) => {
const bezier_point = bezierEquation(c, t);
const line_point = line(s);
return [
bezier_point[0] - line_point[0],
bezier_point[1] - line_point[1],
];
},
t0,
s0,
);
if (!solution) {
return null;
}
const b: Point[] = [];
const s = 1 - curveTightness;
out.push(pointFrom(points[0][0], points[0][1]));
for (let i = 1; i + 2 < points.length; i++) {
const cachedVertArray = points[i];
b[0] = pointFrom(cachedVertArray[0], cachedVertArray[1]);
b[1] = pointFrom(
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] = pointFrom(
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] = pointFrom(points[i + 1][0], points[i + 1][1]);
out.push(b[1], b[2], b[3]);
const [t, s] = solution;
if (t < 0 || t > 1 || s < 0 || s > 1) {
return null;
}
return bezierEquation(c, t);
};
let solution = calculate(initial_guesses[0]);
if (solution) {
return [solution];
}
return out;
solution = calculate(initial_guesses[1]);
if (solution) {
return [solution];
}
solution = calculate(initial_guesses[2]);
if (solution) {
return [solution];
}
return [];
}
/**
* Finds the closest point on the Bezier curve from another point
*
* @param t
* @param controlPoints
* @param x
* @param y
* @param P0
* @param P1
* @param P2
* @param P3
* @param tolerance
* @param maxLevel
* @returns
*/
export const cubicBezierPoint = <Point extends LocalPoint | GlobalPoint>(
t: number,
controlPoints: Curve<Point>,
): Point => {
const [p0, p1, p2, p3] = controlPoints;
export function curveClosestPoint<Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>,
p: Point,
tolerance: number = 1e-3,
): Point | null {
const localMinimum = (
min: number,
max: number,
f: (t: number) => number,
e: number = tolerance,
) => {
let m = min;
let n = max;
let k;
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 pointFrom(x, y);
};
/**
*
* @param point
* @param controlPoints
* @returns
*/
export const cubicBezierDistance = <Point extends LocalPoint | GlobalPoint>(
point: Point,
controlPoints: Curve<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);
} else {
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 =
2 *
Math.sqrt(-b / (3 * a)) *
Math.cos((Math.acos(realPart) + 4 * Math.PI) / 3);
roots.push(root1, root2, root3);
}
return roots;
};
const findClosestParameter = <Point extends LocalPoint | GlobalPoint>(
point: Point,
controlPoints: Curve<Point>,
) => {
// This function finds the parameter t that minimizes the distance between the point
// and any point on the cubic Bezier curve.
const [p0, p1, p2, p3] = controlPoints;
// 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 minDistance = Infinity;
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,
);
if (distance < minDistance) {
minDistance = distance;
closestT = (rootX + rootY) / 2; // Use the average for a smoother result
while (n - m > e) {
k = (n + m) / 2;
if (f(k - e) < f(k + e)) {
n = k;
} else {
m = k;
}
}
return k;
};
const maxSteps = 30;
let closestStep = 0;
for (let min = Infinity, step = 0; step < maxSteps; step++) {
const d = pointDistance(p, bezierEquation(c, step / maxSteps));
if (d < min) {
min = d;
closestStep = step;
}
}
return closestT;
};
const t0 = Math.max((closestStep - 1) / maxSteps, 0);
const t1 = Math.min((closestStep + 1) / maxSteps, 1);
const solution = localMinimum(t0, t1, (t) =>
pointDistance(p, bezierEquation(c, t)),
);
if (!solution) {
return null;
}
return bezierEquation(c, solution);
}
/**
* Determines the distance between a point and the closest point on the
* Bezier curve.
*
* @param c The curve to test
* @param p The point to measure from
*/
export function curvePointDistance<Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>,
p: Point,
) {
const closest = curveClosestPoint(c, p);
if (!closest) {
return 0;
}
return pointDistance(p, closest);
}
/**
* Determines if the parameter is a Curve
*/
export function isCurve<P extends GlobalPoint | LocalPoint>(
v: unknown,
): v is Curve<P> {
return (
Array.isArray(v) &&
v.length === 4 &&
isPoint(v[0]) &&
isPoint(v[1]) &&
isPoint(v[2]) &&
isPoint(v[3])
);
}
function curveBounds<Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>,
): Bounds {
const [P0, P1, P2, P3] = c;
const x = [P0[0], P1[0], P2[0], P3[0]];
const y = [P0[1], P1[1], P2[1], P3[1]];
return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)];
}

View file

@ -0,0 +1,126 @@
import {
ellipse,
ellipseSegmentInterceptPoints,
ellipseIncludesPoint,
ellipseTouchesPoint,
ellipseLineIntersectionPoints,
} from "./ellipse";
import { line } from "./line";
import { pointFrom } from "./point";
import { lineSegment } from "./segment";
import type { Ellipse, GlobalPoint } from "./types";
describe("point and ellipse", () => {
it("point on ellipse", () => {
const target: Ellipse<GlobalPoint> = ellipse(pointFrom(1, 2), 2, 1);
[
pointFrom(1, 3),
pointFrom(1, 1),
pointFrom(3, 2),
pointFrom(-1, 2),
].forEach((p) => {
expect(ellipseTouchesPoint(p, target)).toBe(true);
});
expect(ellipseTouchesPoint(pointFrom(-0.4, 2.7), target, 0.1)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(-0.4, 2.71), target, 0.01)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(2.4, 2.7), target, 0.1)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(2.4, 2.71), target, 0.01)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(2, 1.14), target, 0.1)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(2, 1.14), target, 0.01)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(0, 1.14), target, 0.1)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(0, 1.14), target, 0.01)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(0, 2.8), target)).toBe(false);
expect(ellipseTouchesPoint(pointFrom(2, 1.2), target)).toBe(false);
});
it("point in ellipse", () => {
const target: Ellipse<GlobalPoint> = ellipse(pointFrom(0, 0), 2, 1);
[
pointFrom(0, 1),
pointFrom(0, -1),
pointFrom(2, 0),
pointFrom(-2, 0),
].forEach((p) => {
expect(ellipseIncludesPoint(p, target)).toBe(true);
});
expect(ellipseIncludesPoint(pointFrom(-1, 0.8), target)).toBe(true);
expect(ellipseIncludesPoint(pointFrom(1, -0.8), target)).toBe(true);
// Point on outline
expect(ellipseIncludesPoint(pointFrom(2, 0), target)).toBe(true);
expect(ellipseIncludesPoint(pointFrom(-1, 1), target)).toBe(false);
expect(ellipseIncludesPoint(pointFrom(-1.4, 0.8), target)).toBe(false);
});
});
describe("segment and ellipse", () => {
it("detects outside segment", () => {
const e = ellipse(pointFrom(0, 0), 2, 2);
expect(
ellipseSegmentInterceptPoints(
e,
lineSegment<GlobalPoint>(pointFrom(-100, 0), pointFrom(-10, 0)),
),
).toEqual([]);
expect(
ellipseSegmentInterceptPoints(
e,
lineSegment<GlobalPoint>(pointFrom(-10, 0), pointFrom(10, 0)),
),
).toEqual([pointFrom(-2, 0), pointFrom(2, 0)]);
expect(
ellipseSegmentInterceptPoints(
e,
lineSegment<GlobalPoint>(pointFrom(-10, -2), pointFrom(10, -2)),
),
).toEqual([pointFrom(0, -2)]);
expect(
ellipseSegmentInterceptPoints(
e,
lineSegment<GlobalPoint>(pointFrom(0, -1), pointFrom(0, 1)),
),
).toEqual([]);
});
});
describe("line and ellipse", () => {
const e = ellipse(pointFrom(0, 0), 2, 2);
it("detects outside line", () => {
expect(
ellipseLineIntersectionPoints(
e,
line<GlobalPoint>(pointFrom(-10, -10), pointFrom(10, -10)),
),
).toEqual([]);
});
it("detects line intersecting ellipse", () => {
expect(
ellipseLineIntersectionPoints(
e,
line<GlobalPoint>(pointFrom(0, -1), pointFrom(0, 1)),
),
).toEqual([pointFrom(0, 2), pointFrom(0, -2)]);
expect(
ellipseLineIntersectionPoints(
e,
line<GlobalPoint>(pointFrom(-100, 0), pointFrom(-10, 0)),
).map(([x, y]) => pointFrom(Math.round(x), Math.round(y))),
).toEqual([pointFrom(2, 0), pointFrom(-2, 0)]);
});
it("detects line touching ellipse", () => {
expect(
ellipseLineIntersectionPoints(
e,
line<GlobalPoint>(pointFrom(-2, -2), pointFrom(2, -2)),
),
).toEqual([pointFrom(0, -2)]);
});
});

230
packages/math/ellipse.ts Normal file
View file

@ -0,0 +1,230 @@
import {
pointFrom,
pointDistance,
pointFromVector,
pointsEqual,
} from "./point";
import type {
Ellipse,
GlobalPoint,
Line,
LineSegment,
LocalPoint,
} from "./types";
import { PRECISION } from "./utils";
import {
vector,
vectorAdd,
vectorDot,
vectorFromPoint,
vectorScale,
} from "./vector";
/**
* Construct an Ellipse object from the parameters
*
* @param center The center of the ellipse
* @param angle The slanting of the ellipse in radians
* @param halfWidth Half of the width of a non-slanted version of the ellipse
* @param halfHeight Half of the height of a non-slanted version of the ellipse
* @returns The constructed Ellipse object
*/
export function ellipse<Point extends GlobalPoint | LocalPoint>(
center: Point,
halfWidth: number,
halfHeight: number,
): Ellipse<Point> {
return {
center,
halfWidth,
halfHeight,
} as Ellipse<Point>;
}
/**
* Determines if a point is inside or on the ellipse outline
*
* @param p The point to test
* @param ellipse The ellipse to compare against
* @returns TRUE if the point is inside or on the outline of the ellipse
*/
export const ellipseIncludesPoint = <Point extends GlobalPoint | LocalPoint>(
p: Point,
ellipse: Ellipse<Point>,
) => {
const { center, halfWidth, halfHeight } = ellipse;
const normalizedX = (p[0] - center[0]) / halfWidth;
const normalizedY = (p[1] - center[1]) / halfHeight;
return normalizedX * normalizedX + normalizedY * normalizedY <= 1;
};
/**
* Tests whether a point lies on the outline of the ellipse within a given
* tolerance
*
* @param point The point to test
* @param ellipse The ellipse to compare against
* @param threshold The distance to consider a point close enough to be "on" the outline
* @returns TRUE if the point is on the ellise outline
*/
export const ellipseTouchesPoint = <Point extends GlobalPoint | LocalPoint>(
point: Point,
ellipse: Ellipse<Point>,
threshold = PRECISION,
) => {
return ellipseDistanceFromPoint(point, ellipse) <= threshold;
};
/**
* Determine the shortest euclidean distance from a point to the
* outline of the ellipse
*
* @param p The point to consider
* @param ellipse The ellipse to calculate the distance to
* @returns The eucledian distance
*/
export const ellipseDistanceFromPoint = <
Point extends GlobalPoint | LocalPoint,
>(
p: Point,
ellipse: Ellipse<Point>,
): number => {
const { halfWidth, halfHeight, center } = ellipse;
const a = halfWidth;
const b = halfHeight;
const translatedPoint = vectorAdd(
vectorFromPoint(p),
vectorScale(vectorFromPoint(center), -1),
);
const px = Math.abs(translatedPoint[0]);
const py = Math.abs(translatedPoint[1]);
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(translatedPoint[0]),
b * ty * Math.sign(translatedPoint[1]),
];
return pointDistance(pointFromVector(translatedPoint), pointFrom(minX, minY));
};
/**
* Calculate a maximum of two intercept points for a line going throug an
* ellipse.
*/
export function ellipseSegmentInterceptPoints<
Point extends GlobalPoint | LocalPoint,
>(e: Readonly<Ellipse<Point>>, s: Readonly<LineSegment<Point>>): Point[] {
const rx = e.halfWidth;
const ry = e.halfHeight;
const dir = vectorFromPoint(s[1], s[0]);
const diff = vector(s[0][0] - e.center[0], s[0][1] - e.center[1]);
const mDir = vector(dir[0] / (rx * rx), dir[1] / (ry * ry));
const mDiff = vector(diff[0] / (rx * rx), diff[1] / (ry * ry));
const a = vectorDot(dir, mDir);
const b = vectorDot(dir, mDiff);
const c = vectorDot(diff, mDiff) - 1.0;
const d = b * b - a * c;
const intersections: Point[] = [];
if (d > 0) {
const t_a = (-b - Math.sqrt(d)) / a;
const t_b = (-b + Math.sqrt(d)) / a;
if (0 <= t_a && t_a <= 1) {
intersections.push(
pointFrom(
s[0][0] + (s[1][0] - s[0][0]) * t_a,
s[0][1] + (s[1][1] - s[0][1]) * t_a,
),
);
}
if (0 <= t_b && t_b <= 1) {
intersections.push(
pointFrom(
s[0][0] + (s[1][0] - s[0][0]) * t_b,
s[0][1] + (s[1][1] - s[0][1]) * t_b,
),
);
}
} else if (d === 0) {
const t = -b / a;
if (0 <= t && t <= 1) {
intersections.push(
pointFrom(
s[0][0] + (s[1][0] - s[0][0]) * t,
s[0][1] + (s[1][1] - s[0][1]) * t,
),
);
}
}
return intersections;
}
export function ellipseLineIntersectionPoints<
Point extends GlobalPoint | LocalPoint,
>(
{ center, halfWidth, halfHeight }: Ellipse<Point>,
[g, h]: Line<Point>,
): Point[] {
const [cx, cy] = center;
const x1 = g[0] - cx;
const y1 = g[1] - cy;
const x2 = h[0] - cx;
const y2 = h[1] - cy;
const a =
Math.pow(x2 - x1, 2) / Math.pow(halfWidth, 2) +
Math.pow(y2 - y1, 2) / Math.pow(halfHeight, 2);
const b =
2 *
((x1 * (x2 - x1)) / Math.pow(halfWidth, 2) +
(y1 * (y2 - y1)) / Math.pow(halfHeight, 2));
const c =
Math.pow(x1, 2) / Math.pow(halfWidth, 2) +
Math.pow(y1, 2) / Math.pow(halfHeight, 2) -
1;
const t1 = (-b + Math.sqrt(Math.pow(b, 2) - 4 * a * c)) / (2 * a);
const t2 = (-b - Math.sqrt(Math.pow(b, 2) - 4 * a * c)) / (2 * a);
const candidates = [
pointFrom<Point>(x1 + t1 * (x2 - x1) + cx, y1 + t1 * (y2 - y1) + cy),
pointFrom<Point>(x1 + t2 * (x2 - x1) + cx, y1 + t2 * (y2 - y1) + cy),
].filter((p) => !isNaN(p[0]) && !isNaN(p[1]));
if (candidates.length === 2 && pointsEqual(candidates[0], candidates[1])) {
return [candidates[0]];
}
return candidates;
}

View file

@ -1,70 +0,0 @@
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", () => {
it("distanceToLine", () => {
const point = GA.point(3, 3);
const line = GALine.equation(0, 1, -1);
expect(GAPoint.distanceToLine(point, line)).toEqual(2);
});
it("distanceToLine neg", () => {
const point = GA.point(-3, -3);
const line = GALine.equation(0, 1, -1);
expect(GAPoint.distanceToLine(point, line)).toEqual(-4);
});
});
describe("lines", () => {
it("through", () => {
const a = GA.point(0, 0);
const b = GA.point(2, 0);
expect(toString(GALine.through(a, b))).toEqual(
toString(GALine.equation(0, 2, 0)),
);
});
it("parallel", () => {
const point = GA.point(3, 3);
const line = GALine.equation(0, 1, -1);
const parallel = GALine.parallel(line, 2);
expect(GAPoint.distanceToLine(point, parallel)).toEqual(0);
});
});
describe("translation", () => {
it("points", () => {
const start = point(2, 2);
const move = GATransform.translation(direction(0, 1));
const end = GATransform.apply(move, start);
expect(toString(end)).toEqual(toString(point(2, 3)));
});
it("points 2", () => {
const start = point(2, 2);
const move = GATransform.translation(offset(3, 4));
const end = GATransform.apply(move, start);
expect(toString(end)).toEqual(toString(point(5, 6)));
});
it("lines", () => {
const original = GALine.through(point(2, 2), point(3, 4));
const move = GATransform.translation(offset(3, 4));
const parallel = GATransform.apply(move, original);
expect(toString(parallel)).toEqual(
toString(GALine.through(point(5, 6), point(6, 8))),
);
});
});
describe("rotation", () => {
it("points", () => {
const start = point(2, 2);
const pivot = point(1, 1);
const rotate = GATransform.rotation(pivot, Math.PI / 2);
const end = GATransform.apply(rotate, start);
expect(toString(end)).toEqual(toString(point(2, 0)));
});
});
});

View file

@ -1,317 +0,0 @@
/**
* This is a 2D Projective Geometric Algebra implementation.
*
* For wider context on geometric algebra visit see https://bivector.net.
*
* For this specific algebra see cheatsheet https://bivector.net/2DPGA.pdf.
*
* Converted from generator written by enki, with a ton of added on top.
*
* This library uses 8-vectors to represent points, directions and lines
* in 2D space.
*
* An array `[a, b, c, d, e, f, g, h]` represents a n(8)vector:
* a + b*e0 + c*e1 + d*e2 + e*e01 + f*e20 + g*e12 + h*e012
*
* See GAPoint, GALine, GADirection and GATransform modules for common
* operations.
*/
export type Point = NVector;
export type Direction = NVector;
export type Line = NVector;
export type Transform = NVector;
export const point = (x: number, y: number): Point => [0, 0, 0, 0, y, x, 1, 0];
export const origin = (): Point => [0, 0, 0, 0, 0, 0, 1, 0];
export const direction = (x: number, y: number): Direction => {
const norm = Math.hypot(x, y); // same as `inorm(direction(x, y))`
return [0, 0, 0, 0, y / norm, x / norm, 0, 0];
};
export const offset = (x: number, y: number): Direction => [
0,
0,
0,
0,
y,
x,
0,
0,
];
/// This is the "implementation" part of the library
type NVector = readonly [
number,
number,
number,
number,
number,
number,
number,
number,
];
// These are labels for what each number in an nvector represents
const NVECTOR_BASE = ["1", "e0", "e1", "e2", "e01", "e20", "e12", "e012"];
// Used to represent points, lines and transformations
export const nvector = (value: number = 0, index: number = 0): NVector => {
const result = [0, 0, 0, 0, 0, 0, 0, 0];
if (index < 0 || index > 7) {
throw new Error(`Expected \`index\` between 0 and 7, got \`${index}\``);
}
if (value !== 0) {
result[index] = value;
}
return result as unknown as NVector;
};
const STRING_EPSILON = 0.000001;
export const toString = (nvector: NVector): string => {
const result = nvector
.map((value, index) =>
Math.abs(value) > STRING_EPSILON
? value.toFixed(7).replace(/(\.|0+)$/, "") +
(index > 0 ? NVECTOR_BASE[index] : "")
: null,
)
.filter((representation) => representation != null)
.join(" + ");
return result === "" ? "0" : result;
};
// Reverse the order of the basis blades.
export const reverse = (nvector: NVector): NVector => [
nvector[0],
nvector[1],
nvector[2],
nvector[3],
-nvector[4],
-nvector[5],
-nvector[6],
-nvector[7],
];
// Poincare duality operator.
export const dual = (nvector: NVector): NVector => [
nvector[7],
nvector[6],
nvector[5],
nvector[4],
nvector[3],
nvector[2],
nvector[1],
nvector[0],
];
// Clifford Conjugation
export const conjugate = (nvector: NVector): NVector => [
nvector[0],
-nvector[1],
-nvector[2],
-nvector[3],
-nvector[4],
-nvector[5],
-nvector[6],
nvector[7],
];
// Main involution
export const involute = (nvector: NVector): NVector => [
nvector[0],
-nvector[1],
-nvector[2],
-nvector[3],
nvector[4],
nvector[5],
nvector[6],
-nvector[7],
];
// Multivector addition
export const add = (a: NVector, b: NVector | number): NVector => {
if (isNumber(b)) {
return [a[0] + b, a[1], a[2], a[3], a[4], a[5], a[6], a[7]];
}
return [
a[0] + b[0],
a[1] + b[1],
a[2] + b[2],
a[3] + b[3],
a[4] + b[4],
a[5] + b[5],
a[6] + b[6],
a[7] + b[7],
];
};
// Multivector subtraction
export const sub = (a: NVector, b: NVector | number): NVector => {
if (isNumber(b)) {
return [a[0] - b, a[1], a[2], a[3], a[4], a[5], a[6], a[7]];
}
return [
a[0] - b[0],
a[1] - b[1],
a[2] - b[2],
a[3] - b[3],
a[4] - b[4],
a[5] - b[5],
a[6] - b[6],
a[7] - b[7],
];
};
// The geometric product.
export const mul = (a: NVector, b: NVector | number): NVector => {
if (isNumber(b)) {
return [
a[0] * b,
a[1] * b,
a[2] * b,
a[3] * b,
a[4] * b,
a[5] * b,
a[6] * b,
a[7] * b,
];
}
return [
mulScalar(a, b),
b[1] * a[0] +
b[0] * a[1] -
b[4] * a[2] +
b[5] * a[3] +
b[2] * a[4] -
b[3] * a[5] -
b[7] * a[6] -
b[6] * a[7],
b[2] * a[0] + b[0] * a[2] - b[6] * a[3] + b[3] * a[6],
b[3] * a[0] + b[6] * a[2] + b[0] * a[3] - b[2] * a[6],
b[4] * a[0] +
b[2] * a[1] -
b[1] * a[2] +
b[7] * a[3] +
b[0] * a[4] +
b[6] * a[5] -
b[5] * a[6] +
b[3] * a[7],
b[5] * a[0] -
b[3] * a[1] +
b[7] * a[2] +
b[1] * a[3] -
b[6] * a[4] +
b[0] * a[5] +
b[4] * a[6] +
b[2] * a[7],
b[6] * a[0] + b[3] * a[2] - b[2] * a[3] + b[0] * a[6],
b[7] * a[0] +
b[6] * a[1] +
b[5] * a[2] +
b[4] * a[3] +
b[3] * a[4] +
b[2] * a[5] +
b[1] * a[6] +
b[0] * a[7],
];
};
export const mulScalar = (a: NVector, b: NVector): number =>
b[0] * a[0] + b[2] * a[2] + b[3] * a[3] - b[6] * a[6];
// The outer/exterior/wedge product.
export const meet = (a: NVector, b: NVector): NVector => [
b[0] * a[0],
b[1] * a[0] + b[0] * a[1],
b[2] * a[0] + b[0] * a[2],
b[3] * a[0] + b[0] * a[3],
b[4] * a[0] + b[2] * a[1] - b[1] * a[2] + b[0] * a[4],
b[5] * a[0] - b[3] * a[1] + b[1] * a[3] + b[0] * a[5],
b[6] * a[0] + b[3] * a[2] - b[2] * a[3] + b[0] * a[6],
b[7] * a[0] +
b[6] * a[1] +
b[5] * a[2] +
b[4] * a[3] +
b[3] * a[4] +
b[2] * a[5] +
b[1] * a[6],
];
// The regressive product.
export const join = (a: NVector, b: NVector): NVector => [
joinScalar(a, b),
a[1] * b[7] + a[4] * b[5] - a[5] * b[4] + a[7] * b[1],
a[2] * b[7] - a[4] * b[6] + a[6] * b[4] + a[7] * b[2],
a[3] * b[7] + a[5] * b[6] - a[6] * b[5] + a[7] * b[3],
a[4] * b[7] + a[7] * b[4],
a[5] * b[7] + a[7] * b[5],
a[6] * b[7] + a[7] * b[6],
a[7] * b[7],
];
export const joinScalar = (a: NVector, b: NVector): number =>
a[0] * b[7] +
a[1] * b[6] +
a[2] * b[5] +
a[3] * b[4] +
a[4] * b[3] +
a[5] * b[2] +
a[6] * b[1] +
a[7] * b[0];
// The inner product.
export const dot = (a: NVector, b: NVector): NVector => [
b[0] * a[0] + b[2] * a[2] + b[3] * a[3] - b[6] * a[6],
b[1] * a[0] +
b[0] * a[1] -
b[4] * a[2] +
b[5] * a[3] +
b[2] * a[4] -
b[3] * a[5] -
b[7] * a[6] -
b[6] * a[7],
b[2] * a[0] + b[0] * a[2] - b[6] * a[3] + b[3] * a[6],
b[3] * a[0] + b[6] * a[2] + b[0] * a[3] - b[2] * a[6],
b[4] * a[0] + b[7] * a[3] + b[0] * a[4] + b[3] * a[7],
b[5] * a[0] + b[7] * a[2] + b[0] * a[5] + b[2] * a[7],
b[6] * a[0] + b[0] * a[6],
b[7] * a[0] + b[0] * a[7],
];
export const norm = (a: NVector): number =>
Math.sqrt(Math.abs(a[0] * a[0] - a[2] * a[2] - a[3] * a[3] + a[6] * a[6]));
export const inorm = (a: NVector): number =>
Math.sqrt(Math.abs(a[7] * a[7] - a[5] * a[5] - a[4] * a[4] + a[1] * a[1]));
export const normalized = (a: NVector): NVector => {
const n = norm(a);
if (n === 0 || n === 1) {
return a;
}
const sign = a[6] < 0 ? -1 : 1;
return mul(a, sign / n);
};
export const inormalized = (a: NVector): NVector => {
const n = inorm(a);
if (n === 0 || n === 1) {
return a;
}
return mul(a, 1 / n);
};
const isNumber = (a: any): a is number => typeof a === "number";
export const E0: NVector = nvector(1, 1);
export const E1: NVector = nvector(1, 2);
export const E2: NVector = nvector(1, 3);
export const E01: NVector = nvector(1, 4);
export const E20: NVector = nvector(1, 5);
export const E12: NVector = nvector(1, 6);
export const E012: NVector = nvector(1, 7);
export const I = E012;

View file

@ -1,26 +0,0 @@
import * as GA from "./ga";
import type { Line, Direction, Point } from "./ga";
/**
* A direction is stored as an array `[0, 0, 0, 0, y, x, 0, 0]` representing
* vector `(x, y)`.
*/
export const from = (point: Point): Point => [
0,
0,
0,
0,
point[4],
point[5],
0,
0,
];
export const fromTo = (from: Point, to: Point): Direction =>
GA.inormalized([0, 0, 0, 0, to[4] - from[4], to[5] - from[5], 0, 0]);
export const orthogonal = (direction: Direction): Direction =>
GA.inormalized([0, 0, 0, 0, -direction[5], direction[4], 0, 0]);
export const orthogonalToLine = (line: Line): Direction => GA.mul(line, GA.I);

View file

@ -1,52 +0,0 @@
import * as GA from "./ga";
import type { Line, Point } from "./ga";
/**
* A line is stored as an array `[0, c, a, b, 0, 0, 0, 0]` representing:
* c * e0 + a * e1 + b*e2
*
* This maps to a standard formula `a * x + b * y + c`.
*
* `(-b, a)` corresponds to a 2D vector parallel to the line. The lines
* have a natural orientation, corresponding to that vector.
*
* The magnitude ("norm") of the line is `sqrt(a ^ 2 + b ^ 2)`.
* `c / norm(line)` is the oriented distance from line to origin.
*/
// Returns line with direction (x, y) through origin
export const vector = (x: number, y: number): Line =>
GA.normalized([0, 0, -y, x, 0, 0, 0, 0]);
// For equation ax + by + c = 0.
export const equation = (a: number, b: number, c: number): Line =>
GA.normalized([0, c, a, b, 0, 0, 0, 0]);
export const through = (from: Point, to: Point): Line =>
GA.normalized(GA.join(to, from));
export const orthogonal = (line: Line, point: Point): Line =>
GA.dot(line, point);
// Returns a line perpendicular to the line through `against` and `intersection`
// going through `intersection`.
export const orthogonalThrough = (against: Point, intersection: Point): Line =>
orthogonal(through(against, intersection), intersection);
export const parallel = (line: Line, distance: number): Line => {
const result = line.slice();
result[1] -= distance;
return result as unknown as Line;
};
export const parallelThrough = (line: Line, point: Point): Line =>
orthogonal(orthogonal(point, line), point);
export const distance = (line1: Line, line2: Line): number =>
GA.inorm(GA.meet(line1, line2));
export const angle = (line1: Line, line2: Line): number =>
Math.acos(GA.dot(line1, line2)[0]);
// The orientation of the line
export const sign = (line: Line): number => Math.sign(line[1]);

View file

@ -1,42 +0,0 @@
import * as GA from "./ga";
import * as GALine from "./galines";
import type { Point, Line } from "./ga";
import { join } from "./ga";
export const from = ([x, y]: readonly [number, number]): Point => [
0,
0,
0,
0,
y,
x,
1,
0,
];
export const toTuple = (point: Point): [number, number] => [point[5], point[4]];
export const abs = (point: Point): Point => [
0,
0,
0,
0,
Math.abs(point[4]),
Math.abs(point[5]),
1,
0,
];
export const intersect = (line1: Line, line2: Line): Point =>
GA.normalized(GA.meet(line1, line2));
// Projects `point` onto the `line`.
// The returned point is the closest point on the `line` to the `point`.
export const project = (point: Point, line: Line): Point =>
intersect(GALine.orthogonal(line, point), line);
export const distance = (point1: Point, point2: Point): number =>
GA.norm(join(point1, point2));
export const distanceToLine = (point: Point, line: Line): number =>
GA.joinScalar(point, line);

View file

@ -1,41 +0,0 @@
import * as GA from "./ga";
import type { Line, Direction, Point, Transform } from "./ga";
import * as GADirection from "./gadirections";
/**
* TODO: docs
*/
export const rotation = (pivot: Point, angle: number): Transform =>
GA.add(GA.mul(pivot, Math.sin(angle / 2)), Math.cos(angle / 2));
export const translation = (direction: Direction): Transform => [
1,
0,
0,
0,
-(0.5 * direction[5]),
0.5 * direction[4],
0,
0,
];
export const translationOrthogonal = (
direction: Direction,
distance: number,
): Transform => {
const scale = 0.5 * distance;
return [1, 0, 0, 0, scale * direction[4], scale * direction[5], 0, 0];
};
export const translationAlong = (line: Line, distance: number): Transform =>
GA.add(GA.mul(GADirection.orthogonalToLine(line), 0.5 * distance), 1);
export const compose = (motor1: Transform, motor2: Transform): Transform =>
GA.mul(motor2, motor1);
export const apply = (
motor: Transform,
nvector: Point | Direction | Line,
): Point | Direction | Line =>
GA.normalized(GA.mul(GA.mul(motor, nvector), GA.reverse(motor)));

View file

@ -1,10 +1,10 @@
export * from "./arc";
export * from "./angle";
export * from "./curve";
export * from "./line";
export * from "./point";
export * from "./polygon";
export * from "./range";
export * from "./rectangle";
export * from "./segment";
export * from "./triangle";
export * from "./types";

View file

@ -0,0 +1,31 @@
import { line, linesIntersectAt } from "./line";
import { pointFrom } from "./point";
describe("line-line intersections", () => {
it("should correctly detect intersection at origin", () => {
expect(
linesIntersectAt(
line(pointFrom(-5, -5), pointFrom(5, 5)),
line(pointFrom(5, -5), pointFrom(-5, 5)),
),
).toEqual(pointFrom(0, 0));
});
it("should correctly detect intersection at non-origin", () => {
expect(
linesIntersectAt(
line(pointFrom(0, 0), pointFrom(10, 10)),
line(pointFrom(10, 0), pointFrom(0, 10)),
),
).toEqual(pointFrom(5, 5));
});
it("should correctly detect parallel lines", () => {
expect(
linesIntersectAt(
line(pointFrom(0, 0), pointFrom(0, 10)),
line(pointFrom(10, 0), pointFrom(10, 10)),
),
).toBe(null);
});
});

View file

@ -1,5 +1,5 @@
import { pointCenter, pointFrom, pointRotateRads } from "./point";
import type { GlobalPoint, Line, LocalPoint, Radians } from "./types";
import { pointFrom } from "./point";
import type { GlobalPoint, Line, LocalPoint } from "./types";
/**
* Create a line from two points.
@ -11,54 +11,6 @@ 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,
P,
]): Line<P> {
return line(a, b);
}
/**
* TODO
*
* @param pointArray
* @returns
*/
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
*
* @param l
* @param angle
* @param origin
* @returns
*/
export const lineRotate = <Point extends LocalPoint | GlobalPoint>(
l: Line<Point>,
angle: Radians,
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),
);
};
/**
* Determines the intersection point (unless the lines are parallel) of two
* lines
@ -67,10 +19,10 @@ export const lineRotate = <Point extends LocalPoint | GlobalPoint>(
* @param b
* @returns
*/
export const linesIntersectAt = <Point extends GlobalPoint | LocalPoint>(
export function linesIntersectAt<Point extends GlobalPoint | LocalPoint>(
a: Line<Point>,
b: Line<Point>,
): Point | null => {
): Point | null {
const A1 = a[1][1] - a[0][1];
const B1 = a[0][0] - a[1][0];
const A2 = b[1][1] - b[0][1];
@ -83,4 +35,4 @@ export const linesIntersectAt = <Point extends GlobalPoint | LocalPoint>(
}
return null;
};
}

View file

@ -57,24 +57,9 @@ export function pointFromPair<Point extends GlobalPoint | LocalPoint>(
*/
export function pointFromVector<P extends GlobalPoint | LocalPoint>(
v: Vector,
offset: P = pointFrom(0, 0),
): P {
return v as unknown as P;
}
/**
* Convert the coordiante object to a point.
*
* @param coords The coordinate object with x and y properties
* @returns
*/
export function pointFromCoords<Point extends GlobalPoint | LocalPoint>({
x,
y,
}: {
x: number;
y: number;
}) {
return [x, y] as Point;
return pointFrom<P>(offset[0] + v[0], offset[1] + v[1]);
}
/**
@ -176,36 +161,6 @@ export function pointCenter<P extends LocalPoint | GlobalPoint>(a: P, b: P): P {
return pointFrom((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
* @returns
*/
export function pointAdd<Point extends LocalPoint | GlobalPoint>(
a: Point,
b: Point,
): Point {
return pointFrom(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>(
a: Point,
b: Point,
): Point {
return pointFrom(a[0] - b[0], a[1] - b[1]);
}
/**
* Calculate the distance between two points.
*

View file

@ -0,0 +1,23 @@
import { pointFrom } from "./point";
import { lineSegment, lineSegmentIntersectionPoints } from "./segment";
import type { GlobalPoint, LineSegment, LocalPoint, Rectangle } from "./types";
export function rectangle<P extends GlobalPoint | LocalPoint>(
topLeft: P,
bottomRight: P,
): Rectangle<P> {
return [topLeft, bottomRight] as Rectangle<P>;
}
export function rectangleIntersectLineSegment<
Point extends LocalPoint | GlobalPoint,
>(r: Rectangle<Point>, l: LineSegment<Point>): Point[] {
return [
lineSegment(r[0], pointFrom(r[1][0], r[0][1])),
lineSegment(pointFrom(r[1][0], r[0][1]), r[1]),
lineSegment(r[1], pointFrom(r[0][0], r[1][1])),
lineSegment(pointFrom(r[0][0], r[1][1]), r[0]),
]
.map((s) => lineSegmentIntersectionPoints(l, s))
.filter((i): i is Point => !!i);
}

View file

@ -0,0 +1,21 @@
import { pointFrom } from "./point";
import { lineSegment, lineSegmentIntersectionPoints } from "./segment";
describe("line-segment intersections", () => {
it("should correctly detect intersection", () => {
expect(
lineSegmentIntersectionPoints(
lineSegment(pointFrom(0, 0), pointFrom(5, 0)),
lineSegment(pointFrom(2, -2), pointFrom(3, 2)),
),
).toEqual(pointFrom(2.5, 0));
});
it("should correctly detect non-intersection", () => {
expect(
lineSegmentIntersectionPoints(
lineSegment(pointFrom(0, 0), pointFrom(5, 0)),
lineSegment(pointFrom(3, 1), pointFrom(4, 4)),
),
).toEqual(null);
});
});

View file

@ -1,3 +1,4 @@
import { line, linesIntersectAt } from "./line";
import {
isPoint,
pointCenter,
@ -27,14 +28,6 @@ export function lineSegment<P extends GlobalPoint | LocalPoint>(
return [a, b] as LineSegment<P>;
}
export function lineSegmentFromPointArray<P extends GlobalPoint | LocalPoint>(
pointArray: P[],
): LineSegment<P> | undefined {
return pointArray.length === 2
? lineSegment<P>(pointArray[0], pointArray[1])
: undefined;
}
/**
*
* @param segment
@ -156,3 +149,26 @@ export const distanceToLineSegment = <Point extends LocalPoint | GlobalPoint>(
const dy = y - yy;
return Math.sqrt(dx * dx + dy * dy);
};
/**
* Returns the intersection point of a segment and a line
*
* @param l
* @param s
* @returns
*/
export function lineSegmentIntersectionPoints<
Point extends GlobalPoint | LocalPoint,
>(l: LineSegment<Point>, s: LineSegment<Point>): Point | null {
const candidate = linesIntersectAt(line(l[0], l[1]), line(s[0], s[1]));
if (
!candidate ||
!pointOnLineSegment(candidate, s) ||
!pointOnLineSegment(candidate, l)
) {
return null;
}
return candidate;
}

View file

@ -85,6 +85,13 @@ export type Triangle<P extends GlobalPoint | LocalPoint> = [
_brand: "excalimath__triangle";
};
/**
* A rectangular shape represented by 4 points at its corners
*/
export type Rectangle<P extends GlobalPoint | LocalPoint> = [a: P, b: P] & {
_brand: "excalimath__rectangle";
};
//
// Polygon
//
@ -120,11 +127,14 @@ export type PolarCoords = [
];
/**
* 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".
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 SymmetricArc = {
radius: number;
startAngle: number;
endAngle: number;
export type Ellipse<Point extends GlobalPoint | LocalPoint> = {
center: Point;
halfWidth: number;
halfHeight: number;
} & {
_brand: "excalimath_ellipse";
};

View file

@ -137,12 +137,9 @@ export function vectorMagnitude(v: Vector) {
export const vectorNormalize = (v: Vector): Vector => {
const m = vectorMagnitude(v);
if (m === 0) {
return vector(0, 0);
}
return vector(v[0] / m, v[1] / m);
};
/**
* Project the first vector onto the second vector
*/
export const vectorProjection = (a: Vector, b: Vector) => {
return vectorScale(b, vectorDot(a, b) / vectorDot(b, b));
};

View file

@ -0,0 +1,33 @@
import { diffStringsUnified } from "jest-diff";
expect.extend({
toCloselyEqualPoints(received, expected, precision) {
if (!Array.isArray(received) || !Array.isArray(expected)) {
throw new Error("expected and received are not point arrays");
}
const COMPARE = 1 / Math.pow(10, precision || 2);
const pass = expected.every(
(point, idx) =>
Math.abs(received[idx]?.[0] - point[0]) < COMPARE &&
Math.abs(received[idx]?.[1] - point[1]) < COMPARE,
);
if (!pass) {
return {
message: () => ` The provided array of points are not close enough.
${diffStringsUnified(
JSON.stringify(expected, undefined, 2),
JSON.stringify(received, undefined, 2),
)}`,
pass: false,
};
}
return {
message: () => `expected ${received} to not be close to ${expected}`,
pass: true,
};
},
});