From 3efa8735ef945c2c5959dc784201e67a17944db9 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 24 Sep 2024 19:12:27 +0200 Subject: [PATCH] Ellipse refactor Signed-off-by: Mark Tolmacs --- packages/excalidraw/charts.ts | 4 +- packages/excalidraw/data/restore.ts | 6 +- packages/excalidraw/element/binding.ts | 3 +- packages/math/ellipse.test.ts | 45 +++++ packages/math/ellipse.ts | 231 +++++++++++++++++++++++ packages/math/index.ts | 1 + packages/math/types.ts | 10 + packages/utils/collision.ts | 8 +- packages/utils/geometry/geometry.test.ts | 44 +---- packages/utils/geometry/shape.ts | 166 +--------------- 10 files changed, 301 insertions(+), 217 deletions(-) create mode 100644 packages/math/ellipse.test.ts create mode 100644 packages/math/ellipse.ts diff --git a/packages/excalidraw/charts.ts b/packages/excalidraw/charts.ts index 87d2b34fe3..a6d9140c13 100644 --- a/packages/excalidraw/charts.ts +++ b/packages/excalidraw/charts.ts @@ -1,5 +1,5 @@ import type { Radians } from "../math"; -import { point } from "../math"; +import { point, radians } from "../math"; import { COLOR_PALETTE, DEFAULT_CHART_COLOR_INDEX, @@ -205,7 +205,7 @@ const chartXLabels = ( x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2, y: y + BAR_GAP / 2, width: BAR_WIDTH, - angle: 5.87 as Radians, + angle: radians(5.87), fontSize: 16, textAlign: "center", verticalAlign: "top", diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index f7557a97fc..7faed75755 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -55,8 +55,8 @@ import { getNormalizedGridStep, getNormalizedZoom, } from "../scene"; -import type { LocalPoint, Radians } from "../../math"; -import { pointExtent, isFiniteNumber, point } from "../../math"; +import type { LocalPoint } from "../../math"; +import { pointExtent, isFiniteNumber, point, radians } from "../../math"; type RestoredAppState = Omit< AppState, @@ -153,7 +153,7 @@ const restoreElementWithProperties = < roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness, opacity: element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity, - angle: element.angle || (0 as Radians), + angle: element.angle || radians(0), x: extra.x ?? element.x ?? 0, y: extra.y ?? element.y ?? 0, strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor, diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 3e273b91dd..2df0fa083a 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -73,6 +73,7 @@ import { pointFromPair, pointDistanceSq, clamp, + radians, } from "../../math"; import { segmentIntersectRectangleElement } from "../../utils/geometry/shape"; @@ -845,7 +846,7 @@ export const avoidRectangularCorner = ( element.x + element.width / 2, element.y + element.height / 2, ); - const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians); + const nonRotatedPoint = pointRotateRads(p, center, radians(-element.angle)); if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { // Top left diff --git a/packages/math/ellipse.test.ts b/packages/math/ellipse.test.ts new file mode 100644 index 0000000000..1306da27d5 --- /dev/null +++ b/packages/math/ellipse.test.ts @@ -0,0 +1,45 @@ +import { radians } from "./angle"; +import { pointInEllipse, pointOnEllipse } from "./ellipse"; +import { point } from "./point"; +import type { Ellipse, GlobalPoint } from "./types"; + +describe("point and ellipse", () => { + const ellipse: Ellipse = { + center: point(0, 0), + angle: radians(0), + halfWidth: 2, + halfHeight: 1, + }; + + it("point on ellipse", () => { + [point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => { + expect(pointOnEllipse(p, ellipse)).toBe(true); + }); + expect(pointOnEllipse(point(-1.4, 0.7), ellipse, 0.1)).toBe(true); + expect(pointOnEllipse(point(-1.4, 0.71), ellipse, 0.01)).toBe(true); + + expect(pointOnEllipse(point(1.4, 0.7), ellipse, 0.1)).toBe(true); + expect(pointOnEllipse(point(1.4, 0.71), ellipse, 0.01)).toBe(true); + + expect(pointOnEllipse(point(1, -0.86), ellipse, 0.1)).toBe(true); + expect(pointOnEllipse(point(1, -0.86), ellipse, 0.01)).toBe(true); + + expect(pointOnEllipse(point(-1, -0.86), ellipse, 0.1)).toBe(true); + expect(pointOnEllipse(point(-1, -0.86), ellipse, 0.01)).toBe(true); + + expect(pointOnEllipse(point(-1, 0.8), ellipse)).toBe(false); + expect(pointOnEllipse(point(1, -0.8), ellipse)).toBe(false); + }); + + it("point in ellipse", () => { + [point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => { + expect(pointInEllipse(p, ellipse)).toBe(true); + }); + + expect(pointInEllipse(point(-1, 0.8), ellipse)).toBe(true); + expect(pointInEllipse(point(1, -0.8), ellipse)).toBe(true); + + expect(pointInEllipse(point(-1, 1), ellipse)).toBe(false); + expect(pointInEllipse(point(-1.4, 0.8), ellipse)).toBe(false); + }); +}); diff --git a/packages/math/ellipse.ts b/packages/math/ellipse.ts new file mode 100644 index 0000000000..b033d04944 --- /dev/null +++ b/packages/math/ellipse.ts @@ -0,0 +1,231 @@ +import { radians } from "./angle"; +import { line } from "./line"; +import { + point, + pointDistance, + pointFromVector, + pointRotateRads, +} from "./point"; +import type { Ellipse, GenericPoint, Line } from "./types"; +import { PRECISION } from "./utils"; +import { + vector, + vectorAdd, + vectorDot, + vectorFromPoint, + vectorScale, +} from "./vector"; + +export const pointInEllipse = ( + p: Point, + ellipse: Ellipse, +) => { + const { center, angle, halfWidth, halfHeight } = ellipse; + const translatedPoint = vectorAdd( + vectorFromPoint(p), + vectorScale(vectorFromPoint(center), -1), + ); + const [rotatedPointX, rotatedPointY] = pointRotateRads( + pointFromVector(translatedPoint), + point(0, 0), + radians(-angle), + ); + + return ( + (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) + + (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <= + 1 + ); +}; + +export const pointOnEllipse = ( + point: Point, + ellipse: Ellipse, + threshold = PRECISION, +) => { + return distanceToEllipse(point, ellipse) <= threshold; +}; + +export const ellipseAxes = ( + ellipse: Ellipse, +) => { + const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight; + + const majorAxis = widthGreaterThanHeight + ? ellipse.halfWidth * 2 + : ellipse.halfHeight * 2; + const minorAxis = widthGreaterThanHeight + ? ellipse.halfHeight * 2 + : ellipse.halfWidth * 2; + + return { + majorAxis, + minorAxis, + }; +}; + +export const ellipseFocusToCenter = ( + ellipse: Ellipse, +) => { + const { majorAxis, minorAxis } = ellipseAxes(ellipse); + + return Math.sqrt(majorAxis ** 2 - minorAxis ** 2); +}; + +export const ellipseExtremes = ( + ellipse: Ellipse, +) => { + const { center, angle } = ellipse; + const { majorAxis, minorAxis } = ellipseAxes(ellipse); + + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + const sqSum = majorAxis ** 2 + minorAxis ** 2; + const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle); + + const yMax = Math.sqrt((sqSum - sqDiff) / 2); + const xAtYMax = + (yMax * sqSum * sin * cos) / + (majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2); + + const xMax = Math.sqrt((sqSum + sqDiff) / 2); + const yAtXMax = + (xMax * sqSum * sin * cos) / + (majorAxis ** 2 * cos ** 2 + minorAxis ** 2 * sin ** 2); + const centerVector = vectorFromPoint(center); + + return [ + vectorAdd(vector(xAtYMax, yMax), centerVector), + vectorAdd(vectorScale(vector(xAtYMax, yMax), -1), centerVector), + vectorAdd(vector(xMax, yAtXMax), centerVector), + vectorAdd(vector(xMax, yAtXMax), centerVector), + ]; +}; + +const distanceToEllipse = ( + p: Point, + ellipse: Ellipse, +) => { + const { angle, halfWidth, halfHeight, center } = ellipse; + const a = halfWidth; + const b = halfHeight; + const translatedPoint = vectorAdd( + vectorFromPoint(p), + vectorScale(vectorFromPoint(center), -1), + ); + const [rotatedPointX, rotatedPointY] = pointRotateRads( + pointFromVector(translatedPoint), + point(0, 0), + radians(-angle), + ); + + const px = Math.abs(rotatedPointX); + const py = Math.abs(rotatedPointY); + + let tx = 0.707; + let ty = 0.707; + + for (let i = 0; i < 3; i++) { + const x = a * tx; + const y = b * ty; + + const ex = ((a * a - b * b) * tx ** 3) / a; + const ey = ((b * b - a * a) * ty ** 3) / b; + + const rx = x - ex; + const ry = y - ey; + + const qx = px - ex; + const qy = py - ey; + + const r = Math.hypot(ry, rx); + const q = Math.hypot(qy, qx); + + tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a)); + ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b)); + const t = Math.hypot(ty, tx); + tx /= t; + ty /= t; + } + + const [minX, minY] = [ + a * tx * Math.sign(rotatedPointX), + b * ty * Math.sign(rotatedPointY), + ]; + + return pointDistance(point(rotatedPointX, rotatedPointY), point(minX, minY)); +}; + +/** + * Calculate a maximum of two intercept points for a line going throug an + * ellipse. + */ +export function interceptPointsOfLineAndEllipse( + ellipse: Readonly>, + l: Readonly>, +): Point[] { + const rx = ellipse.halfWidth; + const ry = ellipse.halfHeight; + const nonRotatedLine = line( + pointRotateRads(l[0], ellipse.center, radians(-ellipse.angle)), + pointRotateRads(l[1], ellipse.center, radians(-ellipse.angle)), + ); + const dir = vectorFromPoint(nonRotatedLine[1], nonRotatedLine[0]); + const diff = vector( + nonRotatedLine[0][0] - ellipse.center[0], + nonRotatedLine[0][1] - ellipse.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( + point( + nonRotatedLine[0][0] + + (nonRotatedLine[1][0] - nonRotatedLine[0][0]) * t_a, + nonRotatedLine[0][1] + + (nonRotatedLine[1][1] - nonRotatedLine[0][1]) * t_a, + ), + ); + } + + if (0 <= t_b && t_b <= 1) { + intersections.push( + point( + nonRotatedLine[0][0] + + (nonRotatedLine[1][0] - nonRotatedLine[0][0]) * t_b, + nonRotatedLine[0][1] + + (nonRotatedLine[1][1] - nonRotatedLine[0][1]) * t_b, + ), + ); + } + } else if (d === 0) { + const t = -b / a; + if (0 <= t && t <= 1) { + intersections.push( + point( + nonRotatedLine[0][0] + + (nonRotatedLine[1][0] - nonRotatedLine[0][0]) * t, + nonRotatedLine[0][1] + + (nonRotatedLine[1][1] - nonRotatedLine[0][1]) * t, + ), + ); + } + } + + return intersections.map((point) => + pointRotateRads(point, ellipse.center, ellipse.angle), + ); +} diff --git a/packages/math/index.ts b/packages/math/index.ts index a9ea48a6e0..4383cbcb14 100644 --- a/packages/math/index.ts +++ b/packages/math/index.ts @@ -1,6 +1,7 @@ export * from "./arc"; export * from "./angle"; export * from "./curve"; +export * from "./ellipse"; export * from "./line"; export * from "./path"; export * from "./point"; diff --git a/packages/math/types.ts b/packages/math/types.ts index 6f84c9c7b3..db582ecc4e 100644 --- a/packages/math/types.ts +++ b/packages/math/types.ts @@ -143,3 +143,13 @@ export type Extent = { } & { _brand: "excalimath_extent"; }; + +// an ellipse is specified by its center, angle, and its major and minor axes +// but for the sake of simplicity, we've used halfWidth and halfHeight instead +// in replace of semi major and semi minor axes +export type Ellipse = { + center: Point; + angle: Radians; + halfWidth: number; + halfHeight: number; +}; diff --git a/packages/utils/collision.ts b/packages/utils/collision.ts index f440c1a082..6d901c2ae3 100644 --- a/packages/utils/collision.ts +++ b/packages/utils/collision.ts @@ -1,9 +1,5 @@ import type { Polycurve, Polyline } from "./geometry/shape"; -import { - pointInEllipse, - pointOnEllipse, - type GeometricShape, -} from "./geometry/shape"; +import { type GeometricShape } from "./geometry/shape"; import type { Curve, ViewportPoint } from "../math"; import { lineSegment, @@ -15,6 +11,8 @@ import { type GlobalPoint, type LocalPoint, type Polygon, + pointOnEllipse, + pointInEllipse, } from "../math"; // check if the given point is considered on the given shape's border diff --git a/packages/utils/geometry/geometry.test.ts b/packages/utils/geometry/geometry.test.ts index 740d02d47c..1225ee8139 100644 --- a/packages/utils/geometry/geometry.test.ts +++ b/packages/utils/geometry/geometry.test.ts @@ -1,4 +1,4 @@ -import type { GlobalPoint, LineSegment, Polygon, Radians } from "../../math"; +import type { GlobalPoint, LineSegment, Polygon } from "../../math"; import { point, lineSegment, @@ -7,7 +7,6 @@ import { pointOnPolygon, polygonIncludesPoint, } from "../../math"; -import { pointInEllipse, pointOnEllipse, type Ellipse } from "./shape"; describe("point and line", () => { const s: LineSegment = lineSegment(point(1, 0), point(1, 2)); @@ -47,44 +46,3 @@ describe("point and polygon", () => { expect(polygonIncludesPoint(point(3, 3), poly)).toBe(false); }); }); - -describe("point and ellipse", () => { - const ellipse: Ellipse = { - center: point(0, 0), - angle: 0 as Radians, - halfWidth: 2, - halfHeight: 1, - }; - - it("point on ellipse", () => { - [point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => { - expect(pointOnEllipse(p, ellipse)).toBe(true); - }); - expect(pointOnEllipse(point(-1.4, 0.7), ellipse, 0.1)).toBe(true); - expect(pointOnEllipse(point(-1.4, 0.71), ellipse, 0.01)).toBe(true); - - expect(pointOnEllipse(point(1.4, 0.7), ellipse, 0.1)).toBe(true); - expect(pointOnEllipse(point(1.4, 0.71), ellipse, 0.01)).toBe(true); - - expect(pointOnEllipse(point(1, -0.86), ellipse, 0.1)).toBe(true); - expect(pointOnEllipse(point(1, -0.86), ellipse, 0.01)).toBe(true); - - expect(pointOnEllipse(point(-1, -0.86), ellipse, 0.1)).toBe(true); - expect(pointOnEllipse(point(-1, -0.86), ellipse, 0.01)).toBe(true); - - expect(pointOnEllipse(point(-1, 0.8), ellipse)).toBe(false); - expect(pointOnEllipse(point(1, -0.8), ellipse)).toBe(false); - }); - - it("point in ellipse", () => { - [point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => { - expect(pointInEllipse(p, ellipse)).toBe(true); - }); - - expect(pointInEllipse(point(-1, 0.8), ellipse)).toBe(true); - expect(pointInEllipse(point(1, -0.8), ellipse)).toBe(true); - - expect(pointInEllipse(point(-1, 1), ellipse)).toBe(false); - expect(pointInEllipse(point(-1.4, 0.8), ellipse)).toBe(false); - }); -}); diff --git a/packages/utils/geometry/shape.ts b/packages/utils/geometry/shape.ts index 22f0ec2261..b73c481401 100644 --- a/packages/utils/geometry/shape.ts +++ b/packages/utils/geometry/shape.ts @@ -14,6 +14,8 @@ import type { Curve, + Ellipse, + GenericPoint, LineSegment, Polygon, Radians, @@ -23,18 +25,15 @@ import { curve, lineSegment, point, - pointDistance, pointFromArray, pointFromVector, pointRotateRads, polygon, polygonFromPoints, - PRECISION, segmentsIntersectAt, vector, vectorAdd, vectorFromPoint, - vectorScale, type GlobalPoint, type LocalPoint, } from "../../math"; @@ -70,19 +69,7 @@ export type Polyline = export type Polycurve = Curve[]; -// an ellipse is specified by its center, angle, and its major and minor axes -// but for the sake of simplicity, we've used halfWidth and halfHeight instead -// in replace of semi major and semi minor axes -export type Ellipse = { - center: Point; - angle: Radians; - halfWidth: number; - halfHeight: number; -}; - -export type GeometricShape< - Point extends GlobalPoint | LocalPoint | ViewportPoint, -> = +export type GeometricShape = | { type: "line"; data: LineSegment; @@ -403,150 +390,3 @@ export const segmentIntersectRectangleElement = < .map((s) => segmentsIntersectAt(segment, s)) .filter((i): i is Point => !!i); }; - -const distanceToEllipse = < - Point extends LocalPoint | GlobalPoint | ViewportPoint, ->( - p: Point, - ellipse: Ellipse, -) => { - const { angle, halfWidth, halfHeight, center } = ellipse; - const a = halfWidth; - const b = halfHeight; - const translatedPoint = vectorAdd( - vectorFromPoint(p), - vectorScale(vectorFromPoint(center), -1), - ); - const [rotatedPointX, rotatedPointY] = pointRotateRads( - pointFromVector(translatedPoint), - point(0, 0), - -angle as Radians, - ); - - const px = Math.abs(rotatedPointX); - const py = Math.abs(rotatedPointY); - - let tx = 0.707; - let ty = 0.707; - - for (let i = 0; i < 3; i++) { - const x = a * tx; - const y = b * ty; - - const ex = ((a * a - b * b) * tx ** 3) / a; - const ey = ((b * b - a * a) * ty ** 3) / b; - - const rx = x - ex; - const ry = y - ey; - - const qx = px - ex; - const qy = py - ey; - - const r = Math.hypot(ry, rx); - const q = Math.hypot(qy, qx); - - tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a)); - ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b)); - const t = Math.hypot(ty, tx); - tx /= t; - ty /= t; - } - - const [minX, minY] = [ - a * tx * Math.sign(rotatedPointX), - b * ty * Math.sign(rotatedPointY), - ]; - - return pointDistance(point(rotatedPointX, rotatedPointY), point(minX, minY)); -}; - -export const pointOnEllipse = < - Point extends LocalPoint | GlobalPoint | ViewportPoint, ->( - point: Point, - ellipse: Ellipse, - threshold = PRECISION, -) => { - return distanceToEllipse(point, ellipse) <= threshold; -}; - -export const pointInEllipse = < - Point extends LocalPoint | GlobalPoint | ViewportPoint, ->( - p: Point, - ellipse: Ellipse, -) => { - const { center, angle, halfWidth, halfHeight } = ellipse; - const translatedPoint = vectorAdd( - vectorFromPoint(p), - vectorScale(vectorFromPoint(center), -1), - ); - const [rotatedPointX, rotatedPointY] = pointRotateRads( - pointFromVector(translatedPoint), - point(0, 0), - -angle as Radians, - ); - - return ( - (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) + - (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <= - 1 - ); -}; - -export const ellipseAxes = ( - ellipse: Ellipse, -) => { - const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight; - - const majorAxis = widthGreaterThanHeight - ? ellipse.halfWidth * 2 - : ellipse.halfHeight * 2; - const minorAxis = widthGreaterThanHeight - ? ellipse.halfHeight * 2 - : ellipse.halfWidth * 2; - - return { - majorAxis, - minorAxis, - }; -}; - -export const ellipseFocusToCenter = ( - ellipse: Ellipse, -) => { - const { majorAxis, minorAxis } = ellipseAxes(ellipse); - - return Math.sqrt(majorAxis ** 2 - minorAxis ** 2); -}; - -export const ellipseExtremes = ( - ellipse: Ellipse, -) => { - const { center, angle } = ellipse; - const { majorAxis, minorAxis } = ellipseAxes(ellipse); - - const cos = Math.cos(angle); - const sin = Math.sin(angle); - - const sqSum = majorAxis ** 2 + minorAxis ** 2; - const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle); - - const yMax = Math.sqrt((sqSum - sqDiff) / 2); - const xAtYMax = - (yMax * sqSum * sin * cos) / - (majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2); - - const xMax = Math.sqrt((sqSum + sqDiff) / 2); - const yAtXMax = - (xMax * sqSum * sin * cos) / - (majorAxis ** 2 * cos ** 2 + minorAxis ** 2 * sin ** 2); - const centerVector = vectorFromPoint(center); - - return [ - vectorAdd(vector(xAtYMax, yMax), centerVector), - vectorAdd(vectorScale(vector(xAtYMax, yMax), -1), centerVector), - vectorAdd(vector(xMax, yAtXMax), centerVector), - vectorAdd(vector(xMax, yAtXMax), centerVector), - ]; -};