Ellipse refactor

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs 2024-09-24 19:12:27 +02:00
parent 017047d15e
commit 3efa8735ef
No known key found for this signature in database
10 changed files with 301 additions and 217 deletions

View file

@ -1,5 +1,5 @@
import type { Radians } from "../math"; import type { Radians } from "../math";
import { point } from "../math"; import { point, radians } from "../math";
import { import {
COLOR_PALETTE, COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX, DEFAULT_CHART_COLOR_INDEX,
@ -205,7 +205,7 @@ const chartXLabels = (
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2, x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
y: y + BAR_GAP / 2, y: y + BAR_GAP / 2,
width: BAR_WIDTH, width: BAR_WIDTH,
angle: 5.87 as Radians, angle: radians(5.87),
fontSize: 16, fontSize: 16,
textAlign: "center", textAlign: "center",
verticalAlign: "top", verticalAlign: "top",

View file

@ -55,8 +55,8 @@ import {
getNormalizedGridStep, getNormalizedGridStep,
getNormalizedZoom, getNormalizedZoom,
} from "../scene"; } from "../scene";
import type { LocalPoint, Radians } from "../../math"; import type { LocalPoint } from "../../math";
import { pointExtent, isFiniteNumber, point } from "../../math"; import { pointExtent, isFiniteNumber, point, radians } from "../../math";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@ -153,7 +153,7 @@ const restoreElementWithProperties = <
roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness, roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
opacity: opacity:
element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.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, x: extra.x ?? element.x ?? 0,
y: extra.y ?? element.y ?? 0, y: extra.y ?? element.y ?? 0,
strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor, strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,

View file

@ -73,6 +73,7 @@ import {
pointFromPair, pointFromPair,
pointDistanceSq, pointDistanceSq,
clamp, clamp,
radians,
} from "../../math"; } from "../../math";
import { segmentIntersectRectangleElement } from "../../utils/geometry/shape"; import { segmentIntersectRectangleElement } from "../../utils/geometry/shape";
@ -845,7 +846,7 @@ export const avoidRectangularCorner = (
element.x + element.width / 2, element.x + element.width / 2,
element.y + element.height / 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) { if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
// Top left // Top left

View file

@ -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<GlobalPoint> = {
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);
});
});

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

@ -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 = <Point extends GenericPoint>(
p: Point,
ellipse: Ellipse<Point>,
) => {
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 extends GenericPoint>(
point: Point,
ellipse: Ellipse<Point>,
threshold = PRECISION,
) => {
return distanceToEllipse(point, ellipse) <= threshold;
};
export const ellipseAxes = <Point extends GenericPoint>(
ellipse: Ellipse<Point>,
) => {
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 = <Point extends GenericPoint>(
ellipse: Ellipse<Point>,
) => {
const { majorAxis, minorAxis } = ellipseAxes(ellipse);
return Math.sqrt(majorAxis ** 2 - minorAxis ** 2);
};
export const ellipseExtremes = <Point extends GenericPoint>(
ellipse: Ellipse<Point>,
) => {
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 = <Point extends GenericPoint>(
p: Point,
ellipse: Ellipse<Point>,
) => {
const { angle, halfWidth, halfHeight, center } = ellipse;
const a = halfWidth;
const b = halfHeight;
const translatedPoint = vectorAdd(
vectorFromPoint(p),
vectorScale(vectorFromPoint(center), -1),
);
const [rotatedPointX, rotatedPointY] = pointRotateRads(
pointFromVector(translatedPoint),
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<Point extends GenericPoint>(
ellipse: Readonly<Ellipse<Point>>,
l: Readonly<Line<Point>>,
): 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),
);
}

View file

@ -1,6 +1,7 @@
export * from "./arc"; export * from "./arc";
export * from "./angle"; export * from "./angle";
export * from "./curve"; export * from "./curve";
export * from "./ellipse";
export * from "./line"; export * from "./line";
export * from "./path"; export * from "./path";
export * from "./point"; export * from "./point";

View file

@ -143,3 +143,13 @@ export type Extent = {
} & { } & {
_brand: "excalimath_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<Point extends GenericPoint> = {
center: Point;
angle: Radians;
halfWidth: number;
halfHeight: number;
};

View file

@ -1,9 +1,5 @@
import type { Polycurve, Polyline } from "./geometry/shape"; import type { Polycurve, Polyline } from "./geometry/shape";
import { import { type GeometricShape } from "./geometry/shape";
pointInEllipse,
pointOnEllipse,
type GeometricShape,
} from "./geometry/shape";
import type { Curve, ViewportPoint } from "../math"; import type { Curve, ViewportPoint } from "../math";
import { import {
lineSegment, lineSegment,
@ -15,6 +11,8 @@ import {
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
type Polygon, type Polygon,
pointOnEllipse,
pointInEllipse,
} from "../math"; } from "../math";
// check if the given point is considered on the given shape's border // check if the given point is considered on the given shape's border

View file

@ -1,4 +1,4 @@
import type { GlobalPoint, LineSegment, Polygon, Radians } from "../../math"; import type { GlobalPoint, LineSegment, Polygon } from "../../math";
import { import {
point, point,
lineSegment, lineSegment,
@ -7,7 +7,6 @@ import {
pointOnPolygon, pointOnPolygon,
polygonIncludesPoint, polygonIncludesPoint,
} from "../../math"; } from "../../math";
import { pointInEllipse, pointOnEllipse, type Ellipse } from "./shape";
describe("point and line", () => { describe("point and line", () => {
const s: LineSegment<GlobalPoint> = lineSegment(point(1, 0), point(1, 2)); const s: LineSegment<GlobalPoint> = lineSegment(point(1, 0), point(1, 2));
@ -47,44 +46,3 @@ describe("point and polygon", () => {
expect(polygonIncludesPoint(point(3, 3), poly)).toBe(false); expect(polygonIncludesPoint(point(3, 3), poly)).toBe(false);
}); });
}); });
describe("point and ellipse", () => {
const ellipse: Ellipse<GlobalPoint> = {
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);
});
});

View file

@ -14,6 +14,8 @@
import type { import type {
Curve, Curve,
Ellipse,
GenericPoint,
LineSegment, LineSegment,
Polygon, Polygon,
Radians, Radians,
@ -23,18 +25,15 @@ import {
curve, curve,
lineSegment, lineSegment,
point, point,
pointDistance,
pointFromArray, pointFromArray,
pointFromVector, pointFromVector,
pointRotateRads, pointRotateRads,
polygon, polygon,
polygonFromPoints, polygonFromPoints,
PRECISION,
segmentsIntersectAt, segmentsIntersectAt,
vector, vector,
vectorAdd, vectorAdd,
vectorFromPoint, vectorFromPoint,
vectorScale,
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
} from "../../math"; } from "../../math";
@ -70,19 +69,7 @@ export type Polyline<Point extends GlobalPoint | LocalPoint | ViewportPoint> =
export type Polycurve<Point extends GlobalPoint | LocalPoint | ViewportPoint> = export type Polycurve<Point extends GlobalPoint | LocalPoint | ViewportPoint> =
Curve<Point>[]; Curve<Point>[];
// an ellipse is specified by its center, angle, and its major and minor axes export type GeometricShape<Point extends GenericPoint> =
// 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<Point extends GlobalPoint | LocalPoint | ViewportPoint> = {
center: Point;
angle: Radians;
halfWidth: number;
halfHeight: number;
};
export type GeometricShape<
Point extends GlobalPoint | LocalPoint | ViewportPoint,
> =
| { | {
type: "line"; type: "line";
data: LineSegment<Point>; data: LineSegment<Point>;
@ -403,150 +390,3 @@ export const segmentIntersectRectangleElement = <
.map((s) => segmentsIntersectAt(segment, s)) .map((s) => segmentsIntersectAt(segment, s))
.filter((i): i is Point => !!i); .filter((i): i is Point => !!i);
}; };
const distanceToEllipse = <
Point extends LocalPoint | GlobalPoint | ViewportPoint,
>(
p: Point,
ellipse: Ellipse<Point>,
) => {
const { angle, halfWidth, halfHeight, center } = ellipse;
const a = halfWidth;
const b = halfHeight;
const translatedPoint = vectorAdd(
vectorFromPoint(p),
vectorScale(vectorFromPoint(center), -1),
);
const [rotatedPointX, rotatedPointY] = pointRotateRads(
pointFromVector(translatedPoint),
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<Point>,
threshold = PRECISION,
) => {
return distanceToEllipse(point, ellipse) <= threshold;
};
export const pointInEllipse = <
Point extends LocalPoint | GlobalPoint | ViewportPoint,
>(
p: Point,
ellipse: Ellipse<Point>,
) => {
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 = <Point extends LocalPoint | GlobalPoint>(
ellipse: Ellipse<Point>,
) => {
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 = <Point extends LocalPoint | GlobalPoint>(
ellipse: Ellipse<Point>,
) => {
const { majorAxis, minorAxis } = ellipseAxes(ellipse);
return Math.sqrt(majorAxis ** 2 - minorAxis ** 2);
};
export const ellipseExtremes = <Point extends LocalPoint | GlobalPoint>(
ellipse: Ellipse<Point>,
) => {
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),
];
};