ellipse and arc refactors

This commit is contained in:
Mark Tolmacs 2024-09-25 11:03:35 +02:00
parent 3efa8735ef
commit 392dd5b0b8
No known key found for this signature in database
9 changed files with 125 additions and 94 deletions

View file

@ -42,8 +42,6 @@ const calculateTranslation = (
maxY: selectionBounds[3], maxY: selectionBounds[3],
midX: (selectionBounds[0] + selectionBounds[2]) / 2, midX: (selectionBounds[0] + selectionBounds[2]) / 2,
midY: (selectionBounds[1] + selectionBounds[3]) / 2, midY: (selectionBounds[1] + selectionBounds[3]) / 2,
width: selectionBounds[2] - selectionBounds[0],
height: selectionBounds[3] - selectionBounds[1],
}; };
const groupBounds = getCommonBounds(group); const groupBounds = getCommonBounds(group);
const groupBoundingBox = { const groupBoundingBox = {
@ -53,8 +51,6 @@ const calculateTranslation = (
maxY: groupBounds[3], maxY: groupBounds[3],
midX: (groupBounds[0] + groupBounds[2]) / 2, midX: (groupBounds[0] + groupBounds[2]) / 2,
midY: (groupBounds[1] + groupBounds[3]) / 2, midY: (groupBounds[1] + groupBounds[3]) / 2,
width: groupBounds[2] - groupBounds[0],
height: groupBounds[3] - groupBounds[1],
}; };
const [min, max]: ["minX" | "minY", "maxX" | "maxY"] = const [min, max]: ["minX" | "minY", "maxX" | "maxY"] =

View file

@ -16,7 +16,7 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { getBoundTextShape } from "../shapes"; import { getBoundTextShape } from "../shapes";
import type { GlobalPoint, LocalPoint, Polygon } from "../../math"; import type { GlobalPoint, Polygon } from "../../math";
import { pathIsALoop, isPointWithinBounds, point } from "../../math"; import { pathIsALoop, isPointWithinBounds, point } from "../../math";
import { LINE_CONFIRM_THRESHOLD } from "../constants"; import { LINE_CONFIRM_THRESHOLD } from "../constants";
@ -48,10 +48,10 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
return isDraggableFromInside || isImageElement(element); return isDraggableFromInside || isImageElement(element);
}; };
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = { export type HitTestArgs = {
sceneCoords: Point; sceneCoords: GlobalPoint;
element: ExcalidrawElement; element: ExcalidrawElement;
shape: GeometricShape<Point>; shape: GeometricShape<GlobalPoint>;
threshold?: number; threshold?: number;
frameNameBound?: FrameNameBounds | null; frameNameBound?: FrameNameBounds | null;
}; };
@ -62,7 +62,7 @@ export const hitElementItself = ({
shape, shape,
threshold = 10, threshold = 10,
frameNameBound = null, frameNameBound = null,
}: HitTestArgs<GlobalPoint>) => { }: HitTestArgs) => {
let hit = shouldTestInside(element) let hit = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape ? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders" // we would need `onShape` as well to include the "borders"
@ -97,7 +97,7 @@ export const hitElementBoundingBox = (
}; };
export const hitElementBoundingBoxOnly = ( export const hitElementBoundingBoxOnly = (
hitArgs: HitTestArgs<GlobalPoint>, hitArgs: HitTestArgs,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
return ( return (

View file

@ -6,7 +6,7 @@ describe("point on arc", () => {
it("should detect point on simple arc", () => { it("should detect point on simple arc", () => {
expect( expect(
isPointOnSymmetricArc( isPointOnSymmetricArc(
arc(1, radians(-Math.PI / 4), radians(Math.PI / 4)), arc(point(0, 0), 1, radians(-Math.PI / 4), radians(Math.PI / 4)),
point(0.92291667, 0.385), point(0.92291667, 0.385),
), ),
).toBe(true); ).toBe(true);
@ -14,7 +14,7 @@ describe("point on arc", () => {
it("should not detect point outside of a simple arc", () => { it("should not detect point outside of a simple arc", () => {
expect( expect(
isPointOnSymmetricArc( isPointOnSymmetricArc(
arc(1, radians(-Math.PI / 4), radians(Math.PI / 4)), arc(point(0, 0), 1, radians(-Math.PI / 4), radians(Math.PI / 4)),
point(-0.92291667, 0.385), point(-0.92291667, 0.385),
), ),
).toBe(false); ).toBe(false);
@ -22,7 +22,7 @@ describe("point on arc", () => {
it("should not detect point with good angle but incorrect radius", () => { it("should not detect point with good angle but incorrect radius", () => {
expect( expect(
isPointOnSymmetricArc( isPointOnSymmetricArc(
arc(1, radians(-Math.PI / 4), radians(Math.PI / 4)), arc(point(0, 0), 1, radians(-Math.PI / 4), radians(Math.PI / 4)),
point(-0.5, 0.5), point(-0.5, 0.5),
), ),
).toBe(false); ).toBe(false);

View file

@ -1,5 +1,7 @@
import { cartesian2Polar } from "./angle"; import { cartesian2Polar, radians } from "./angle";
import type { GenericPoint, Radians, SymmetricArc } from "./types"; import { ellipse, interceptPointsOfLineAndEllipse } from "./ellipse";
import { point } from "./point";
import type { GenericPoint, LineSegment, Radians, SymmetricArc } from "./types";
import { PRECISION } from "./utils"; import { PRECISION } from "./utils";
/** /**
@ -12,8 +14,13 @@ import { PRECISION } from "./utils";
* @param endAngle The end angle with 0 radians being the "northest" point * @param endAngle The end angle with 0 radians being the "northest" point
* @returns The constructed symmetric arc * @returns The constructed symmetric arc
*/ */
export function arc(radius: number, startAngle: Radians, endAngle: Radians) { export function arc<Point extends GenericPoint>(
return { radius, startAngle, endAngle } as SymmetricArc; center: Point,
radius: number,
startAngle: Radians,
endAngle: Radians,
) {
return { center, radius, startAngle, endAngle } as SymmetricArc<Point>;
} }
/** /**
@ -21,10 +28,12 @@ export function arc(radius: number, startAngle: Radians, endAngle: Radians) {
* is part of a circle contour centered on 0, 0. * is part of a circle contour centered on 0, 0.
*/ */
export function isPointOnSymmetricArc<P extends GenericPoint>( export function isPointOnSymmetricArc<P extends GenericPoint>(
{ radius: arcRadius, startAngle, endAngle }: SymmetricArc, { center, radius: arcRadius, startAngle, endAngle }: SymmetricArc<P>,
point: P, p: P,
): boolean { ): boolean {
const [radius, angle] = cartesian2Polar(point); const [radius, angle] = cartesian2Polar(
point(p[0] - center[0], p[1] - center[1]),
);
return startAngle < endAngle return startAngle < endAngle
? Math.abs(radius - arcRadius) < PRECISION && ? Math.abs(radius - arcRadius) < PRECISION &&
@ -32,3 +41,27 @@ export function isPointOnSymmetricArc<P extends GenericPoint>(
endAngle >= angle endAngle >= angle
: startAngle <= angle || endAngle >= angle; : startAngle <= angle || endAngle >= angle;
} }
/**
* Returns the intersection point(s) of a line segment represented by a start
* point and end point and a symmetric arc.
*/
export function interceptOfSymmetricArcAndSegment<Point extends GenericPoint>(
a: Readonly<SymmetricArc<Point>>,
l: Readonly<LineSegment<Point>>,
): Point[] {
return interceptPointsOfLineAndEllipse(
ellipse(a.center, radians(0), a.radius, a.radius),
l,
).filter((candidate) => {
const [candidateRadius, candidateAngle] = cartesian2Polar(
point(candidate[0] - a.center[0], candidate[1] - a.center[1]),
);
return a.startAngle < a.endAngle
? Math.abs(a.radius - candidateRadius) < 0.0000001 &&
a.startAngle <= candidateAngle &&
a.endAngle >= candidateAngle
: a.startAngle <= candidateAngle || a.endAngle >= candidateAngle;
});
}

View file

@ -1,45 +1,54 @@
import { radians } from "./angle"; import { radians } from "./angle";
import { pointInEllipse, pointOnEllipse } from "./ellipse"; import {
ellipse,
interceptPointsOfLineAndEllipse,
pointInEllipse,
pointOnEllipse,
} from "./ellipse";
import { point } from "./point"; import { point } from "./point";
import { lineSegment } from "./segment";
import type { Ellipse, GlobalPoint } from "./types"; import type { Ellipse, GlobalPoint } from "./types";
describe("point and ellipse", () => { describe("point and ellipse", () => {
const ellipse: Ellipse<GlobalPoint> = { const target: Ellipse<GlobalPoint> = ellipse(point(0, 0), radians(0), 2, 1);
center: point(0, 0),
angle: radians(0),
halfWidth: 2,
halfHeight: 1,
};
it("point on ellipse", () => { it("point on ellipse", () => {
[point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => { [point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => {
expect(pointOnEllipse(p, ellipse)).toBe(true); expect(pointOnEllipse(p, target)).toBe(true);
}); });
expect(pointOnEllipse(point(-1.4, 0.7), ellipse, 0.1)).toBe(true); expect(pointOnEllipse(point(-1.4, 0.7), target, 0.1)).toBe(true);
expect(pointOnEllipse(point(-1.4, 0.71), ellipse, 0.01)).toBe(true); expect(pointOnEllipse(point(-1.4, 0.71), target, 0.01)).toBe(true);
expect(pointOnEllipse(point(1.4, 0.7), ellipse, 0.1)).toBe(true); expect(pointOnEllipse(point(1.4, 0.7), target, 0.1)).toBe(true);
expect(pointOnEllipse(point(1.4, 0.71), ellipse, 0.01)).toBe(true); expect(pointOnEllipse(point(1.4, 0.71), target, 0.01)).toBe(true);
expect(pointOnEllipse(point(1, -0.86), ellipse, 0.1)).toBe(true); expect(pointOnEllipse(point(1, -0.86), target, 0.1)).toBe(true);
expect(pointOnEllipse(point(1, -0.86), ellipse, 0.01)).toBe(true); expect(pointOnEllipse(point(1, -0.86), target, 0.01)).toBe(true);
expect(pointOnEllipse(point(-1, -0.86), ellipse, 0.1)).toBe(true); expect(pointOnEllipse(point(-1, -0.86), target, 0.1)).toBe(true);
expect(pointOnEllipse(point(-1, -0.86), ellipse, 0.01)).toBe(true); expect(pointOnEllipse(point(-1, -0.86), target, 0.01)).toBe(true);
expect(pointOnEllipse(point(-1, 0.8), ellipse)).toBe(false); expect(pointOnEllipse(point(-1, 0.8), target)).toBe(false);
expect(pointOnEllipse(point(1, -0.8), ellipse)).toBe(false); expect(pointOnEllipse(point(1, -0.8), target)).toBe(false);
}); });
it("point in ellipse", () => { it("point in ellipse", () => {
[point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => { [point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => {
expect(pointInEllipse(p, ellipse)).toBe(true); expect(pointInEllipse(p, target)).toBe(true);
}); });
expect(pointInEllipse(point(-1, 0.8), ellipse)).toBe(true); expect(pointInEllipse(point(-1, 0.8), target)).toBe(true);
expect(pointInEllipse(point(1, -0.8), ellipse)).toBe(true); expect(pointInEllipse(point(1, -0.8), target)).toBe(true);
expect(pointInEllipse(point(-1, 1), ellipse)).toBe(false); expect(pointInEllipse(point(-1, 1), target)).toBe(false);
expect(pointInEllipse(point(-1.4, 0.8), ellipse)).toBe(false); expect(pointInEllipse(point(-1.4, 0.8), target)).toBe(false);
});
});
describe("line and ellipse", () => {
it("detects outside segment", () => {
const l = lineSegment<GlobalPoint>(point(-100, 0), point(-10, 0));
const e = ellipse(point(0, 0), radians(0), 2, 2);
expect(interceptPointsOfLineAndEllipse(e, l).length).toBe(0);
}); });
}); });

View file

@ -6,7 +6,7 @@ import {
pointFromVector, pointFromVector,
pointRotateRads, pointRotateRads,
} from "./point"; } from "./point";
import type { Ellipse, GenericPoint, Line } from "./types"; import type { Ellipse, GenericPoint, LineSegment, Radians } from "./types";
import { PRECISION } from "./utils"; import { PRECISION } from "./utils";
import { import {
vector, vector,
@ -16,6 +16,20 @@ import {
vectorScale, vectorScale,
} from "./vector"; } from "./vector";
export function ellipse<Point extends GenericPoint>(
center: Point,
angle: Radians,
halfWidth: number,
halfHeight: number,
): Ellipse<Point> {
return {
center,
angle,
halfWidth,
halfHeight,
} as Ellipse<Point>;
}
export const pointInEllipse = <Point extends GenericPoint>( export const pointInEllipse = <Point extends GenericPoint>(
p: Point, p: Point,
ellipse: Ellipse<Point>, ellipse: Ellipse<Point>,
@ -162,19 +176,19 @@ const distanceToEllipse = <Point extends GenericPoint>(
* ellipse. * ellipse.
*/ */
export function interceptPointsOfLineAndEllipse<Point extends GenericPoint>( export function interceptPointsOfLineAndEllipse<Point extends GenericPoint>(
ellipse: Readonly<Ellipse<Point>>, e: Readonly<Ellipse<Point>>,
l: Readonly<Line<Point>>, l: Readonly<LineSegment<Point>>,
): Point[] { ): Point[] {
const rx = ellipse.halfWidth; const rx = e.halfWidth;
const ry = ellipse.halfHeight; const ry = e.halfHeight;
const nonRotatedLine = line( const nonRotatedLine = line(
pointRotateRads(l[0], ellipse.center, radians(-ellipse.angle)), pointRotateRads(l[0], e.center, radians(-e.angle)),
pointRotateRads(l[1], ellipse.center, radians(-ellipse.angle)), pointRotateRads(l[1], e.center, radians(-e.angle)),
); );
const dir = vectorFromPoint(nonRotatedLine[1], nonRotatedLine[0]); const dir = vectorFromPoint(nonRotatedLine[1], nonRotatedLine[0]);
const diff = vector( const diff = vector(
nonRotatedLine[0][0] - ellipse.center[0], nonRotatedLine[0][0] - e.center[0],
nonRotatedLine[0][1] - ellipse.center[1], nonRotatedLine[0][1] - e.center[1],
); );
const mDir = vector(dir[0] / (rx * rx), dir[1] / (ry * ry)); const mDir = vector(dir[0] / (rx * rx), dir[1] / (ry * ry));
const mDiff = vector(diff[0] / (rx * rx), diff[1] / (ry * ry)); const mDiff = vector(diff[0] / (rx * rx), diff[1] / (ry * ry));
@ -226,6 +240,6 @@ export function interceptPointsOfLineAndEllipse<Point extends GenericPoint>(
} }
return intersections.map((point) => return intersections.map((point) =>
pointRotateRads(point, ellipse.center, ellipse.angle), pointRotateRads(point, e.center, e.angle),
); );
} }

View file

@ -126,7 +126,8 @@ export type Curve<Point extends GlobalPoint | LocalPoint | ViewportPoint> = [
* Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle * 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" * corresponds to (1, 0) cartesian coordinates (point), i.e. to the "right"
*/ */
export type SymmetricArc = { export type SymmetricArc<Point extends GenericPoint> = {
center: Point;
radius: number; radius: number;
startAngle: Radians; startAngle: Radians;
endAngle: Radians; endAngle: Radians;
@ -152,4 +153,6 @@ export type Ellipse<Point extends GenericPoint> = {
angle: Radians; angle: Radians;
halfWidth: number; halfWidth: number;
halfHeight: number; halfHeight: number;
} & {
_brand: "excalimath_ellipse";
}; };

View file

@ -1,6 +1,6 @@
import type { Polycurve, Polyline } from "./geometry/shape"; import type { Polycurve, Polyline } from "./geometry/shape";
import { type GeometricShape } from "./geometry/shape"; import { type GeometricShape } from "./geometry/shape";
import type { Curve, ViewportPoint } from "../math"; import type { Curve, GenericPoint } from "../math";
import { import {
lineSegment, lineSegment,
point, point,
@ -8,23 +8,16 @@ import {
pointOnLineSegment, pointOnLineSegment,
pointOnPolygon, pointOnPolygon,
polygonFromPoints, polygonFromPoints,
type GlobalPoint,
type LocalPoint,
type Polygon,
pointOnEllipse, pointOnEllipse,
pointInEllipse, 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
export const isPointOnShape = < export const isPointOnShape = <Point extends GenericPoint>(
Point extends GlobalPoint | LocalPoint | ViewportPoint,
>(
point: Point, point: Point,
shape: GeometricShape<Point>, shape: GeometricShape<Point>,
tolerance = 0, tolerance = 0,
) => { ): boolean => {
// get the distance from the given point to the given element
// check if the distance is within the given epsilon range
switch (shape.type) { switch (shape.type) {
case "polygon": case "polygon":
return pointOnPolygon(point, shape.data, tolerance); return pointOnPolygon(point, shape.data, tolerance);
@ -39,12 +32,12 @@ export const isPointOnShape = <
case "polycurve": case "polycurve":
return pointOnPolycurve(point, shape.data, tolerance); return pointOnPolycurve(point, shape.data, tolerance);
default: default:
throw Error(`shape ${shape} is not implemented`); throw Error(`Shape ${shape} is not implemented`);
} }
}; };
// check if the given point is considered inside the element's border // check if the given point is considered inside the element's border
export const isPointInShape = <Point extends GlobalPoint | LocalPoint>( export const isPointInShape = <Point extends GenericPoint>(
p: Point, p: Point,
shape: GeometricShape<Point>, shape: GeometricShape<Point>,
) => { ) => {
@ -69,17 +62,7 @@ export const isPointInShape = <Point extends GlobalPoint | LocalPoint>(
} }
}; };
// check if the given element is in the given bounds const pointOnPolycurve = <Point extends GenericPoint>(
export const isPointInBounds = <Point extends GlobalPoint | LocalPoint>(
point: Point,
bounds: Polygon<Point>,
) => {
return polygonIncludesPoint(point, bounds);
};
const pointOnPolycurve = <
Point extends LocalPoint | GlobalPoint | ViewportPoint,
>(
point: Point, point: Point,
polycurve: Polycurve<Point>, polycurve: Polycurve<Point>,
tolerance: number, tolerance: number,
@ -87,9 +70,7 @@ const pointOnPolycurve = <
return polycurve.some((curve) => pointOnCurve(point, curve, tolerance)); return polycurve.some((curve) => pointOnCurve(point, curve, tolerance));
}; };
const cubicBezierEquation = < const cubicBezierEquation = <Point extends GenericPoint>(
Point extends LocalPoint | GlobalPoint | ViewportPoint,
>(
curve: Curve<Point>, curve: Curve<Point>,
) => { ) => {
const [p0, p1, p2, p3] = curve; const [p0, p1, p2, p3] = curve;
@ -101,9 +82,7 @@ const cubicBezierEquation = <
p0[idx] * Math.pow(t, 3); p0[idx] * Math.pow(t, 3);
}; };
const polyLineFromCurve = < const polyLineFromCurve = <Point extends GenericPoint>(
Point extends LocalPoint | GlobalPoint | ViewportPoint,
>(
curve: Curve<Point>, curve: Curve<Point>,
segments = 10, segments = 10,
): Polyline<Point> => { ): Polyline<Point> => {
@ -125,9 +104,7 @@ const polyLineFromCurve = <
return lineSegments; return lineSegments;
}; };
export const pointOnCurve = < export const pointOnCurve = <Point extends GenericPoint>(
Point extends LocalPoint | GlobalPoint | ViewportPoint,
>(
point: Point, point: Point,
curve: Curve<Point>, curve: Curve<Point>,
threshold: number, threshold: number,
@ -135,9 +112,7 @@ export const pointOnCurve = <
return pointOnPolyline(point, polyLineFromCurve(curve), threshold); return pointOnPolyline(point, polyLineFromCurve(curve), threshold);
}; };
export const pointOnPolyline = < export const pointOnPolyline = <Point extends GenericPoint>(
Point extends LocalPoint | GlobalPoint | ViewportPoint,
>(
point: Point, point: Point,
polyline: Polyline<Point>, polyline: Polyline<Point>,
threshold = 10e-5, threshold = 10e-5,

View file

@ -23,6 +23,7 @@ import type {
} from "../../math"; } from "../../math";
import { import {
curve, curve,
ellipse,
lineSegment, lineSegment,
point, point,
pointFromArray, pointFromArray,
@ -178,12 +179,12 @@ export const getEllipseShape = <Point extends GlobalPoint | LocalPoint>(
return { return {
type: "ellipse", type: "ellipse",
data: { data: ellipse(
center: point(x + width / 2, y + height / 2), point(x + width / 2, y + height / 2),
angle, angle,
halfWidth: width / 2, width / 2,
halfHeight: height / 2, height / 2,
}, ),
}; };
}; };