diff --git a/docs/examples/example-simple/README.md b/docs/examples/example-simple/README.md
new file mode 100644
index 000000000..c802852ce
--- /dev/null
+++ b/docs/examples/example-simple/README.md
@@ -0,0 +1,4 @@
+# example-simple
+[example.html](./example.html) | [example.ts](./example.ts)
+
+An example that creates a single editor and adds it to the document.
diff --git a/docs/examples/example-simple/build-config.json b/docs/examples/example-simple/build-config.json
new file mode 100644
index 000000000..3187836f2
--- /dev/null
+++ b/docs/examples/example-simple/build-config.json
@@ -0,0 +1,8 @@
+{
+ "bundledFiles": [
+ {
+ "name": "jsdraw",
+ "inPath": "./example.ts"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/examples/example-simple/example.html b/docs/examples/example-simple/example.html
new file mode 100644
index 000000000..9626da01b
--- /dev/null
+++ b/docs/examples/example-simple/example.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+ Simple Example | js-draw examples
+
+
+
+ Loading...
+
+
+
\ No newline at end of file
diff --git a/docs/examples/example-simple/example.ts b/docs/examples/example-simple/example.ts
new file mode 100644
index 000000000..90d146138
--- /dev/null
+++ b/docs/examples/example-simple/example.ts
@@ -0,0 +1,31 @@
+import * as jsdraw from 'js-draw';
+import 'js-draw/styles';
+
+const editor = new jsdraw.Editor(document.body);
+editor.addToolbar();
+
+// TODO: DERIVED FROM MDN. REPLACE!!!
+// See original: https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths
+editor.loadFromSVG(`
+
+`);
+
+/*
+M 10 315
+ L 110 215
+ A 30 50 0 0 1 162.55 162.45
+ L 172.55 152.45
+ A 30 50 -45 0 1 215.1 109.9
+ L 315 10
+*/
\ No newline at end of file
diff --git a/docs/examples/example-simple/package.json b/docs/examples/example-simple/package.json
new file mode 100644
index 000000000..1f682b9ec
--- /dev/null
+++ b/docs/examples/example-simple/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@js-draw/example-simple",
+ "version": "0.0.1",
+ "description": "Simple example",
+ "license": "MIT",
+ "private": true,
+ "scripts": {
+ "bundle": "ts-node scripts/bundle.ts",
+ "watchBundle": "ts-node scripts/watchBundle.ts",
+ "build": "build-tool build",
+ "watch": "build-tool watch"
+ },
+ "dependencies": {
+ "js-draw": "^0.23.1"
+ },
+ "devDependencies": {
+ "@js-draw/build-tool": "^0.0.1"
+ }
+}
diff --git a/docs/examples/example-simple/tsconfig.json b/docs/examples/example-simple/tsconfig.json
new file mode 100644
index 000000000..03322c49e
--- /dev/null
+++ b/docs/examples/example-simple/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../../tsconfig.json",
+
+ "include": [
+ "**/*.ts",
+ ]
+}
diff --git a/package-lock.json b/package-lock.json
index 892670dbb..f5ea94eb6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -59,6 +59,16 @@
"@js-draw/build-tool": "^0.0.1"
}
},
+ "docs/examples/example-simple": {
+ "version": "0.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "js-draw": "^0.23.1"
+ },
+ "devDependencies": {
+ "@js-draw/build-tool": "^0.0.1"
+ }
+ },
"node_modules/@ampproject/remapping": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz",
@@ -1340,6 +1350,10 @@
"resolved": "docs/examples/example-custom-tools",
"link": true
},
+ "node_modules/@js-draw/example-simple": {
+ "resolved": "docs/examples/example-simple",
+ "link": true
+ },
"node_modules/@lerna/child-process": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@lerna/child-process/-/child-process-7.0.1.tgz",
diff --git a/packages/js-draw/src/math/Mat33.test.ts b/packages/js-draw/src/math/Mat33.test.ts
index 05dfbb10e..8ead8f236 100644
--- a/packages/js-draw/src/math/Mat33.test.ts
+++ b/packages/js-draw/src/math/Mat33.test.ts
@@ -241,4 +241,41 @@ describe('Mat33 tests', () => {
0, 0, 1,
));
});
+
+ describe('should find correct eigenvalues and eigenvectors of 2x2 submatricies', () => {
+ it('should return eigenvalue 1 for identity matrix', () => {
+ const eigvals = Mat33.identity.mat22EigenValues();
+ expect(eigvals).toHaveLength(1);
+ expect(eigvals[0]).toBeCloseTo(1);
+
+ // Even though the identity matrix has one eigenvalue, it should be observed that
+ // any vector is an eigenvector. Thus, it should have (at least) the coordinate axes.
+ const eigvecs = Mat33.identity.mat22EigenVectors();
+ expect(eigvecs).toHaveLength(2);
+ expect(eigvecs[0]).objEq(Vec2.of(1, 0));
+ expect(eigvecs[1]).objEq(Vec2.of(0, 1));
+ });
+
+ it('should return no eigenvalues/eigenvectors for a small rotation', () => {
+ const mat = Mat33.zRotation(Math.PI / 4);
+ expect(mat.mat22EigenValues()).toHaveLength(0);
+ expect(mat.mat22EigenVectors()).toHaveLength(0);
+ });
+
+ it('a scaling and shearing matrix should have a single eigenvalue', () => {
+ const mat = new Mat33(
+ 2, 1, 0,
+ 0, 2, 0,
+ 0, 0, 1
+ );
+
+ const eigvals = mat.mat22EigenValues();
+ expect(eigvals).toHaveLength(1);
+ expect(eigvals[0]).toBeCloseTo(2);
+
+ const eigvecs = mat.mat22EigenVectors();
+ expect(eigvecs).toHaveLength(1);
+ expect(eigvecs[0]).objEq(Vec2.of(0, -1));
+ });
+ });
});
diff --git a/packages/js-draw/src/math/Mat33.ts b/packages/js-draw/src/math/Mat33.ts
index 1b2b2099b..793430709 100644
--- a/packages/js-draw/src/math/Mat33.ts
+++ b/packages/js-draw/src/math/Mat33.ts
@@ -1,5 +1,6 @@
import { Point2, Vec2 } from './Vec2';
import Vec3 from './Vec3';
+import solveQuadratic from './polynomial/solveQuadratic';
export type Mat33Array = [
number, number, number,
@@ -221,6 +222,76 @@ export default class Mat33 {
);
}
+ /**
+ * Returns the eigenvalues of the top left corner of this matrix.
+ */
+ public mat22EigenValues(): [number,number]|[number]|[] {
+ // If A is the 2x2 submatrix in the top-left corner of this, we want
+ // to find all vectors v such that
+ // ∃λ∈ℝ s.t. Av = λv.
+ // Using Algebra,
+ // Av = λv ⟹ Av - λv = 0
+ // ⟹ Av - λIv = 0
+ // ⟹ (A - λI)v = 0
+ // ⟹ v ∈ Kernel(A - λI)
+ // We're intereseted in λ for which Kernel(A - λI) ≠ ∅,
+ // thus, where A - λI is not invertable, hence,
+ // 0 = det(A - λI)
+ // ⎡ a1 a2 ⎤ ⎡ λ 0 ⎤
+ // = det( ⎣ b1 b2 ⎦ - ⎣ 0 λ ⎦ )
+ // ⎡ a1-λ a2 ⎤
+ // = det ⎣ b1 b2-λ ⎦
+ // = (a1-λ)(b2-λ) - (a2)(b1)
+ // = a1 b2 - λ b2 - λ a1 + λ² - (a2)(b1)
+ // = λ² + (λ)(-a1 - b2) + a1 b2 - a2 b1
+ // Solving gives the eigenvalues.
+ const [ lambda1, lambda2 ] = solveQuadratic(
+ 1, -this.a1 - this.b2, this.a1 * this.b2 - this.a2 * this.b1
+ );
+
+ // No solutions
+ if (isNaN(lambda1) && isNaN(lambda2)) {
+ return [];
+ }
+
+ // One solution (float equality check is okay here because solveQuadratic
+ // produces two solutions that are *exactly* the same).
+ if (lambda1 === lambda2) {
+ return [lambda1];
+ }
+
+ return [ lambda1, lambda2 ];
+ }
+
+ /** Returns the unit eigenvectors of the top left 2x2 submatrix of this. */
+ public mat22EigenVectors(): Vec2[] {
+ const eigvals = this.mat22EigenValues();
+ const eigvecs = [];
+
+ for (const eigval of eigvals) {
+ let row1 = Vec2.of(this.a1 - eigval, this.a2);
+ const row2 = Vec2.of(this.b1, this.b2 - eigval);
+
+ if (row1.eq(Vec2.zero)) {
+ row1 = row2;
+ }
+
+ // Because an eigenvalue, λ, corresponds to a case where
+ // A - λI is singular, we must have that row1 is a multiple of row2.
+ // Hence, for all φ ∈ ℝ,
+ // φ v.x + φ v.y = 0 is equivalent to the system of equations above.
+
+ if (row1.eq(Vec2.zero)) {
+ eigvecs.push(Vec2.unitX);
+ eigvecs.push(Vec2.unitY);
+ } else {
+ eigvecs.push(Vec2.of(row1.x, -row1.y).normalized());
+ }
+ }
+
+ return eigvecs;
+ }
+
/** @returns true iff this is the identity matrix. */
public isIdentity(): boolean {
if (this === Mat33.identity) {
diff --git a/packages/js-draw/src/math/Vec3.ts b/packages/js-draw/src/math/Vec3.ts
index 8afa2cd22..da83ecc69 100644
--- a/packages/js-draw/src/math/Vec3.ts
+++ b/packages/js-draw/src/math/Vec3.ts
@@ -51,7 +51,8 @@ export default class Vec3 {
/**
* Return this' angle in the XY plane (treats this as a Vec2).
*
- * This is equivalent to `Math.atan2(vec.y, vec.x)`.
+ * This is equivalent to `Math.atan2(vec.y, vec.x)`. Thus, the output angle is in the range
+ * `(-pi, pi]`.
*/
public angle(): number {
return Math.atan2(this.y, this.x);
diff --git a/packages/js-draw/src/math/polynomial/solveQuadratic.ts b/packages/js-draw/src/math/polynomial/solveQuadratic.ts
index 61f15c388..66eb2505b 100644
--- a/packages/js-draw/src/math/polynomial/solveQuadratic.ts
+++ b/packages/js-draw/src/math/polynomial/solveQuadratic.ts
@@ -3,7 +3,8 @@
* Solves an equation of the form ax² + bx + c = 0.
* The larger solution is returned first.
*
- * It is possible that the two solutions returned by this function are the same.
+ * If there are no solutions, returns `[NaN, NaN]`. If there is one solution,
+ * repeats the solution twice in the result.
*/
const solveQuadratic = (a: number, b: number, c: number): [number, number] => {
// See also https://en.wikipedia.org/wiki/Quadratic_formula
diff --git a/packages/js-draw/src/math/shapes/Abstract2DShape.ts b/packages/js-draw/src/math/shapes/Abstract2DShape.ts
index 65d7ddf1b..d066404de 100644
--- a/packages/js-draw/src/math/shapes/Abstract2DShape.ts
+++ b/packages/js-draw/src/math/shapes/Abstract2DShape.ts
@@ -3,7 +3,7 @@ import { Point2 } from '../Vec2';
import Rect2 from './Rect2';
abstract class Abstract2DShape {
- protected static readonly smallValue = 1e-12;
+ protected static readonly smallValue = 1e-13;
/**
* @returns the distance from `point` to this shape. If `point` is within this shape,
diff --git a/packages/js-draw/src/math/shapes/Ellipse.test.ts b/packages/js-draw/src/math/shapes/Ellipse.test.ts
new file mode 100644
index 000000000..74356da88
--- /dev/null
+++ b/packages/js-draw/src/math/shapes/Ellipse.test.ts
@@ -0,0 +1,244 @@
+import Mat33 from '../Mat33';
+import { Point2, Vec2 } from '../Vec2';
+import Ellipse from './Ellipse';
+import LineSegment2 from './LineSegment2';
+
+describe('Ellipse', () => {
+ type ProcessEllipseCallback = (
+ ellipse: Ellipse,
+
+ // Foci
+ f1: Point2, f2: Point2,
+
+ // Angle of the ellipse
+ angle: number,
+
+ // t is a parameter that can be used to initialize other variables.
+ t: number
+ )=>void;
+ const forEachEllipseOnMainTestPath = (handleEllipse: ProcessEllipseCallback) => {
+ for (let t = 0; t < 1; t += 0.1) {
+ // Select parameters based on t
+ const x1 = Math.cos(t * 2 * Math.PI) * 10;
+ const y1 = Math.sin(t * 3 * Math.PI) * 3;
+ const dist = (Math.cos(t * 12 * Math.PI) + 1) * 4;
+ const angle = t * 2 * Math.PI;
+ const semimajorAxisLen = 16 + t;
+
+ // Compute the foci locations from the parameters.
+ const x2 = x1 + dist * Math.cos(angle);
+ const y2 = y1 + dist * Math.sin(angle);
+
+ const f1 = Vec2.of(x1, y1);
+ const f2 = Vec2.of(x2, y2);
+
+ const ellipse = new Ellipse(f1, f2, semimajorAxisLen);
+ handleEllipse(ellipse, f1, f2, angle, t);
+ }
+ };
+
+ describe('should compute correct center, angle, and transform', () => {
+ it('for the unit circle', () => {
+ const center = Vec2.zero;
+ const radius = 1;
+ const circle = new Ellipse(center, center, radius);
+ expect(circle.rx).toBeCloseTo(radius);
+ expect(circle.ry).toBeCloseTo(radius);
+ expect(circle.transform).objEq(Mat33.identity);
+ expect(circle.center).objEq(center);
+ expect(circle.angle).toBeCloseTo(0);
+ });
+
+ it('for a circle', () => {
+ for (let x = -1; x < 1; x += 0.3) {
+ for (let y = -1; y < 1; y += 0.3) {
+ const center = Vec2.of(x, y);
+ const ellipse = new Ellipse(center, center, 1);
+ expect(ellipse.center).objEq(center);
+ expect(ellipse.angle).toBeCloseTo(0);
+ expect(ellipse.ry).toBeCloseTo(1);
+
+ // Should also map the unit x and unit y vectors to translated unit x and
+ // unit y vectors (points on the unit circle -> points on the translated unit
+ // circle).
+ expect(
+ ellipse.transform.transformVec2(Vec2.unitX)
+ ).objEq(ellipse.center.plus(Vec2.unitX));
+ expect(
+ ellipse.transform.transformVec2(Vec2.unitY)
+ ).objEq(ellipse.center.plus(Vec2.unitY));
+ }
+ }
+ });
+
+ it('for an ellipse on the x axis', () => {
+ for (let x1 = -1; x1 < 2; x1 += 0.3) {
+ for (let x2 = -2; x2 < 1; x2 += 0.3) {
+ const f1 = Vec2.of(x1, 0);
+ const f2 = Vec2.of(x2, 0);
+
+ const ellipse = new Ellipse(f1, f2, 12);
+ expect(ellipse.center).objEq(Vec2.of((f1.x + f2.x) / 2, 0));
+
+ // Should produce an angle that results in a line with 0 slope
+ expect(Math.tan(ellipse.angle)).toBeCloseTo(0);
+
+ expect(
+ ellipse.hasPointOnBoundary(ellipse.transform.transformVec2(Vec2.unitX))
+ ).toBe(true);
+ expect(
+ ellipse.hasPointOnBoundary(ellipse.transform.transformVec2(Vec2.unitY))
+ ).toBe(true);
+ }
+ }
+ });
+
+ it('for a rotated ellipse', () => {
+ forEachEllipseOnMainTestPath((ellipse, f1, f2, angle, t) => {
+ expect(ellipse.center).objEq(f1.plus(f2).times(0.5));
+
+ // Angle should produce the same line as ellipse.angle
+ expect(Math.abs(Math.cos(ellipse.angle))).toBeCloseTo(Math.abs(Math.cos(angle)));
+ expect(Math.abs(Math.sin(ellipse.angle))).toBeCloseTo(Math.abs(Math.sin(angle)));
+
+ // Should also transform a point from the unit circle to this ellipse
+ const circlePoint = Vec2.of(Math.cos(t * 2 * Math.PI), Math.sin(t * 2 * Math.PI));
+ const ellipsePoint = ellipse.transform.transformVec2(circlePoint);
+ expect(ellipse.hasPointOnBoundary(ellipsePoint)).toBe(true);
+ });
+ });
+ });
+
+ it('parameterForPoint should return the parameter value for a given point', () => {
+ forEachEllipseOnMainTestPath((ellipse) => {
+ for (let i = -Math.PI + 0.001; i < Math.PI; i += 0.3) {
+ expect(ellipse.parameterForPoint(ellipse.at(i))).toBeCloseTo(i);
+ }
+ });
+ });
+
+ describe('should compute correct signed distance', () => {
+ it('for a circle', () => {
+ for (const center of [ Vec2.zero, Vec2.of(1, 0.5)]) {
+ const radius = 1;
+ const circle = new Ellipse(center, center, radius);
+
+ expect(
+ circle.signedDistance(Vec2.of(2, 0).plus(center))
+ ).toBeCloseTo(2 - radius);
+ expect(
+ circle.signedDistance(Vec2.of(-1, -1).plus(center))
+ ).toBeCloseTo(Math.hypot(1 - 1/Math.SQRT2, 1 - 1/Math.SQRT2));
+ expect(circle.signedDistance(center)).toBeCloseTo(-radius);
+ }
+ });
+
+ it('for a rotated ellipse', () => {
+ forEachEllipseOnMainTestPath((ellipse, _f1, _f2, _angle, t) => {
+ // Should return a negative signed distance for points within
+ // the ellipse
+ const delta = Vec2.of(
+ ellipse.rx * Math.cos(t) * 0.9, ellipse.ry * Math.sin(t) * 0.9
+ );
+ expect(ellipse.signedDistance(ellipse.center.plus(delta))).toBeLessThan(0);
+ expect(ellipse.signedDistance(ellipse.center)).toBeLessThan(0);
+
+ // Should return a positive signed distance for points outside of the ellipse.
+ expect(
+ ellipse.signedDistance(ellipse.center.plus(delta.times(2)))
+ ).toBeGreaterThan(0);
+
+ // Should return zero for points on the ellipse,
+ // length of normal for points translated by normal.
+ for (let t2 = 0; t2 < 2 * Math.PI; t2 += 0.3) {
+ expect(ellipse.hasPointOnBoundary(ellipse.at(t2))).toBe(true);
+ expect(
+ ellipse.signedDistance(ellipse.at(t2))
+ ).toBeCloseTo(0);
+
+ // Shifting along the normal should produce the distance
+ // gone along the normal.
+ let normalDist = Math.tan(t2 + 0.1);
+
+ if (Math.abs(normalDist) > Math.min(ellipse.rx, ellipse.ry)) {
+ normalDist = Math.min(ellipse.rx, ellipse.ry) * Math.sin(t2 + 0.1);
+ }
+
+ const normal = ellipse.derivativeAt(t2).orthog().normalized();
+ const p2 = ellipse.at(t2).plus(normal.times(-normalDist));
+
+ expect(
+ ellipse.signedDistance(p2)
+ ).toBeCloseTo(normalDist);
+ }
+ });
+ });
+ });
+
+ describe('should compute line segment intersection correctly', () => {
+ it('for a circle', () => {
+ const center = Vec2.zero;
+ const radius = 1;
+ const circle = new Ellipse(center, center, radius);
+
+ const seg1 = new LineSegment2(Vec2.of(-10, 0), Vec2.of(10, 0));
+ const intersections1 = circle.intersectsLineSegment(seg1);
+ expect(intersections1).toHaveLength(2);
+ expect(intersections1[0]).objEq(Vec2.of(1, 0));
+ expect(intersections1[1]).objEq(Vec2.of(-1, 0));
+
+ const seg2 = new LineSegment2(Vec2.of(0, -10), Vec2.of(0, 10));
+ const intersections2 = circle.intersectsLineSegment(seg2);
+ expect(intersections2).toHaveLength(2);
+ expect(intersections2[0]).objEq(Vec2.of(0, 1));
+ expect(intersections2[1]).objEq(Vec2.of(0, -1));
+ });
+ });
+
+ describe('should compute correct tight bounding box', () => {
+ it('for a circle', () => {
+ for (const center of [ Vec2.zero, Vec2.of(1, 0.5)]) {
+ const radius = 1;
+ const circle = new Ellipse(center, center, radius);
+
+ const bbox = circle.getTightBoundingBox();
+ expect(bbox.size).objEq(Vec2.of(radius * 2, radius * 2));
+ expect(bbox.center).objEq(center);
+ }
+ });
+
+ it('for a stretched circle', () => {
+ const rx = 2;
+ const stretchedCircle = new Ellipse(Vec2.of(-1, 0), Vec2.of(1, 0), rx);
+
+ const bbox = stretchedCircle.getTightBoundingBox();
+ expect(bbox.center).objEq(Vec2.zero);
+ expect(bbox.width).toBe(4); // 2rx
+ expect(bbox.height).toBe(2 * Math.sqrt(rx ** 2 - 1 ** 2)); // 2ry
+ });
+ });
+
+ it('should compute correct XY extrema', () => {
+ forEachEllipseOnMainTestPath(ellipse => {
+ const extrema = ellipse.getXYExtrema();
+ expect(extrema).toHaveLength(4);
+
+ // Should be in correct order (small x, big x, small y, big y).
+ expect(extrema[0].x).toBeLessThanOrEqual(extrema[1].x);
+ expect(extrema[2].y).toBeLessThanOrEqual(extrema[3].y);
+
+ // Neighboring points should not be as extreme
+ for (let delta = -0.1; delta <= 0.1; delta += 0.04) {
+ for (let i = 0; i < 4; i++) {
+ const extremaParam = ellipse.parameterForPoint(extrema[i])!;
+ const neighborPoint = ellipse.at(extremaParam + delta);
+
+ expect(neighborPoint.x).toBeGreaterThan(extrema[0].x);
+ expect(neighborPoint.x).toBeLessThan(extrema[1].x);
+ expect(neighborPoint.y).toBeGreaterThan(extrema[2].y);
+ expect(neighborPoint.y).toBeLessThan(extrema[3].y);
+ }
+ }
+ });
+ });
+});
diff --git a/packages/js-draw/src/math/shapes/Ellipse.ts b/packages/js-draw/src/math/shapes/Ellipse.ts
new file mode 100644
index 000000000..4af7ed8b7
--- /dev/null
+++ b/packages/js-draw/src/math/shapes/Ellipse.ts
@@ -0,0 +1,470 @@
+import Mat33 from '../Mat33';
+import { Point2, Vec2 } from '../Vec2';
+import Vec3 from '../Vec3';
+import solveQuadratic from '../polynomial/solveQuadratic';
+import Abstract2DShape from './Abstract2DShape';
+import LineSegment2 from './LineSegment2';
+import Rect2 from './Rect2';
+
+/**
+ * We define an ellipse with two foci, `f1` and `f2`, and a distance, `rx`.
+ *
+ * The ellipse is the set `{ p ∈ ℝ² : ‖f1 - p‖ + ‖f2 - p‖ = 2rx }`.
+ *
+ * We call `rx` the **semimajor axis** and `2rx` the **major axis**.
+ */
+class Ellipse extends Abstract2DShape {
+ /** Center of the ellipse: The point between its two foci. */
+ public readonly center: Point2;
+
+ /** Length of the semiminor axis */
+ public readonly ry: number;
+
+ /** Angle the ellipse is rotated (counter clockwise, radians). */
+ public readonly angle: number;
+
+ /**
+ * Transformation from the unit circle to this ellipse. As such,
+ * `this.transform @ point`
+ * for `point` on the unit circle produces a point on this ellipse.
+ */
+ public readonly transform: Mat33;
+
+
+ /**
+ * Creates a new Ellipse ([about ellipses](https://mathworld.wolfram.com/Ellipse.html)).
+ *
+ * @param f1 Position of the first focus
+ * @param f2 Position of the second focus
+ * @param rx Length of the semimajor axis
+ */
+ public constructor(
+ // Focus 1
+ public readonly f1: Point2,
+
+ // Focus 2
+ public readonly f2: Point2,
+
+ // Length of the semimajor axis
+ public readonly rx: number,
+ ) {
+ super();
+
+ // The center is halfway between f1 and f2.
+ this.center = f1.lerp(f2, 0.5);
+
+ // With a point on the (rotated) y axis, the lines from the foci to the
+ // point form two symmetric triangles:
+ // /|\
+ // / | \
+ // / |ry\
+ // / | \
+ // f1▔▔▔▔▔▔▔▔▔f2
+ // l l
+ //
+ // l² + ry² = a² ⟹ ry² = a² - l².
+ //
+ // Because the point is on the ellipse,
+ // a + a = 2rx ⟹ a = rx.
+ // Thus,
+ // ry² = rx² - l²
+ const l = this.center.minus(f1).length();
+ this.ry = Math.sqrt(this.rx * this.rx - l * l);
+
+ // Angle between the x-axis in the XY plane and the major axis of this ellipse.
+ this.angle = this.f2.minus(this.center).angle();
+
+ // Transforms a point on the unit circle to this ellipse.
+ this.transform = Mat33.translation(this.center).rightMul(
+ Mat33.zRotation(this.angle)
+ ).rightMul(
+ Mat33.scaling2D(Vec2.of(this.rx, this.ry))
+ );
+ }
+
+ // public static fromTransform(
+ // transform: Mat33,
+ // ) {
+ // // See https://math.stackexchange.com/a/2147944
+
+ // const xAxis = Vec2.unitX;
+ // const yAxis = Vec2.unitY;
+ // const zero = Vec2.zero;
+
+ // const center = transform.transformVec2(zero);
+ // const right = transform.transformVec3(xAxis);
+ // const top = transform.transformVec3(yAxis);
+
+ // // The two foci lie along the longer axis.
+ // // rx is half the length of this longer axis.
+
+ // // (rx-ℓ) ℓ ℓ (rx-ℓ)
+ // // |-----x---o---x-----|
+ // // f1 f2
+ // // where ℓ is the distance between the foci.
+
+ // const upVec = top.minus(center);
+ // const rightVec = right.minus(center);
+
+ // const ry = Math.min(upVec.length(), rightVec.length());
+ // const rx = Math.max(upVec.length(), rightVec.length());
+
+ // // Choose the x-axis to be the longer of the two axes
+
+ // let f1, f2;
+ // if (top.length() > right.length()) {
+ // f1 = center.plus();
+ // } else {
+ // ;
+ // }
+ // }
+
+ /**
+ * @returns a point on this ellipse, given a parameter value, `t ∈ [0, 2π)`.
+ *
+ * Because `t` is treated as an angle, `at(3π)` is equivalent to `at(π)`. Thus,
+ * `at(a + 2πk) = at(a)` for all `k ∈ ℤ`.
+ */
+ public at(t: number) {
+ // See https://math.stackexchange.com/q/2645689
+
+ // Start with an unrotated ellipse:
+ // v₀(t) = (rx * cos(t), ry * sin(t))
+ // Rotate it:
+ //
+ // ((cos ϑ, -sin ϑ)
+ // v₁(t) = (sin ϑ, cos ϑ)) * v₀(t)
+ //
+ // = (rx (cos ϑ)(cos t) - ry (sin ϑ)(sin t),
+ // rx (sin ϑ)(cos t) + ry (sin t)(cos ϑ)).
+ //
+ // Finally, center it:
+ // v(t) = v₁(t) + center
+ const cosAngle = Math.cos(this.angle);
+ const sinAngle = Math.sin(this.angle);
+ const sint = Math.sin(t);
+ const cost = Math.cos(t);
+ return Vec2.of(
+ this.rx * cosAngle * cost - this.ry * sint * sinAngle,
+ this.rx * sinAngle * cost + this.ry * sint * cosAngle,
+ ).plus(this.center);
+ }
+
+ public derivativeAt(t: number) {
+ const cosAngle = Math.cos(this.angle);
+ const sinAngle = Math.sin(this.angle);
+ const sint = Math.sin(t);
+ const cost = Math.cos(t);
+ return Vec2.of(
+ this.rx * cosAngle * (-sint) - this.ry * cost * sinAngle,
+ this.rx * sinAngle * (-sint) + this.ry * cost * cosAngle
+ );
+ }
+
+ /**
+ * Returns the parameter value that produces the given `point`.
+ *
+ * `point` should be a point on this ellipse. If it is not, returns `null`.
+ */
+ public parameterForPoint(point: Point2): number|null {
+ if (!this.containsPoint(point)) {
+ return null;
+ }
+
+ return this.parameterForPointUnchecked(point);
+ }
+
+ /**
+ * Like {@link parameterForPoint}, but does not verify that `point` is on this
+ * ellipse.
+ */
+ public parameterForPointUnchecked(point: Point2) {
+ const pointOnCircle = this.transform.inverse().transformVec2(point);
+ return pointOnCircle.angle();
+ }
+
+ /**
+ * Returns the points on this ellipse with minimum/maximum x/y values.
+ *
+ * Return values are in the form
+ * ```
+ * [
+ * point with minimum X,
+ * point with maximum X,
+ * point with minimum Y,
+ * point with maximum Y,
+ * ]
+ * ```
+ */
+ public getXYExtrema(): [ Point2, Point2, Point2, Point2 ] {
+ const cosAngle = Math.cos(this.angle);
+ const sinAngle = Math.sin(this.angle);
+
+ // See https://www.desmos.com/calculator/dpj1wrif16
+ //
+ // At extrema, derivativeAt(t) has a zero x or y component. Thus,
+ // for angle = α, yComponent(t) = 0 and thus,
+ // rx * sin(α) * (-sin t) + ry * cos t * cos(α) = 0
+ // ⟹ rx (sin α) (sin t) = ry (cos t) (cos α)
+ // ⟹ (sin t) = (ry (cos t)(cos α)) / (rx (sin α))
+ // ⟹ (sin t)/(cos t) = (ry (cos α)) / (rx (sin α))
+ // ⟹ t = Arctan2(ry (cos α), rx (sin α)) ± 2πk
+ const yExtremaT = Math.atan2(this.ry * cosAngle, this.rx * sinAngle);
+ let yExtrema1 = this.at(yExtremaT);
+ let yExtrema2 = this.at(yExtremaT + Math.PI);
+
+ if (yExtrema1.y > yExtrema2.y) {
+ const tmp = yExtrema1;
+ yExtrema1 = yExtrema2;
+ yExtrema2 = tmp;
+ }
+
+ // Similarly, for angle α, xComponent(t) = 0, thus,
+ // rx (cos α) (-sin t) - ry (sin α) (cos t) = 0
+ // ⟹ rx (cos α) (-sin t) = ry (sin α) (cos t)
+ // ⟹ -sin t = ry (cos t) (sin α) / (rx (cos α))
+ // ⟹ -(sin t)/(cos t) = ry (sin α) / (rx cos α)
+ // ⟹ tan t = -ry (sin α) / (rx cos α)
+ const xExtremaT = Math.atan2(-this.ry * sinAngle, this.rx * cosAngle);
+ let xExtrema1 = this.at(xExtremaT);
+ let xExtrema2 = this.at(xExtremaT + Math.PI);
+
+ if (xExtrema1.x > xExtrema2.x) {
+ const tmp = xExtrema1;
+ xExtrema1 = xExtrema2;
+ xExtrema2 = tmp;
+ }
+
+ return [ xExtrema1, xExtrema2, yExtrema1, yExtrema2 ];
+ }
+
+ /** @inheritdoc */
+ public override getTightBoundingBox(): Rect2 {
+ return Rect2.bboxOf(this.getXYExtrema());
+ }
+
+
+ /**
+ * Returns the points (if any) at which the line containing `line` intersects
+ * this.
+ */
+ private lineIntersections(line: LineSegment2): [Point2, Point2]|null {
+ // Convert the segment into something in the space of the unit circle for
+ // easier math.
+ const segInCircleSpace = line.transformedBy(this.transform.inverse());
+
+ // Intersect segInCircleSpace with the unit circle:
+ //
+ // The unit circle satisfies x² + y² = 1 for all x, y in the circle.
+ // Thus, where the line segment intersects the circle,
+ // 1 = (x(t))² + (y(t))²
+ // = (x₀ + vₓt)² + (y₀ + vᵧt)² <-- for v = segInCircleSpace.direction
+ // = x₀² + 2x₀vₓt + vₓ²t² + y₀² + 2y₀vᵧt + vᵧ²t²
+ // = vₓ²t² + vᵧ²t² + 2x₀vₓt + 2y₀vᵧt + x₀² + y₀²
+ // ⟹ 0 = (t²)(vₓ² + vᵧ²) + (t)(2x₀vₓ + 2y₀vᵧ) + x₀² + y₀² - 1
+ // Hence, we solve a quadratic for t:
+ const v = segInCircleSpace.direction;
+ const p1 = segInCircleSpace.p1;
+ const a = v.x * v.x + v.y * v.y;
+ const b = 2 * p1.x * v.x + 2 * p1.y * v.y;
+ const c = p1.x * p1.x + p1.y * p1.y - 1;
+
+ const [ lineParam1, lineParam2 ] = solveQuadratic(a, b, c);
+
+ // No solutions?
+ if (isNaN(lineParam1) || isNaN(lineParam2)) {
+ return null;
+ }
+
+ // Turn the line parameters into points:
+ const sol1 = p1.plus(v.times(lineParam1));
+ const sol2 = p1.plus(v.times(lineParam2));
+
+ // Return solutions transformed back
+ return [
+ this.transform.transformVec2(sol1),
+ this.transform.transformVec2(sol2),
+ ];
+ }
+
+ /**
+ * Returns the (at most two) parameter values for the intersection of the line containing
+ * the given segment with this.
+ *
+ * This returns two (possibly the same) parameter values, or null
+ * if the line containing `line` does not intersect this.
+ */
+ private parmeterForLineIntersection(line: LineSegment2): [number, number]|null {
+ const points = this.lineIntersections(line);
+
+ if (!points) {
+ return null;
+ }
+
+ const invTransform = this.transform.inverse();
+ const unitCirclePoint1 = invTransform.transformVec2(points[0]);
+ const unitCirclePoint2 = invTransform.transformVec2(points[1]);
+
+ const param1 = unitCirclePoint1.angle();
+ const param2 = unitCirclePoint2.angle();
+
+ // Parameters remain the same under transformation
+ return [ param1, param2 ];
+ }
+
+ public override intersectsLineSegment(lineSegment: LineSegment2): Point2[] {
+ const intersectionsOnLine = this.lineIntersections(lineSegment);
+
+ // No intersections
+ if (intersectionsOnLine === null) {
+ return [];
+ }
+
+ const intersectionsOnSeg = intersectionsOnLine
+ .filter(p => lineSegment.containsPoint(p));
+
+ if (intersectionsOnSeg.length === 2) {
+ if (intersectionsOnSeg[0].eq(intersectionsOnSeg[1])) {
+ return [ intersectionsOnSeg[0] ];
+ }
+ }
+
+ return intersectionsOnSeg;
+ }
+
+ public getSemiMajorAxisDirection() {
+ return this.f2.eq(this.center) ? Vec2.of(1, 0) : this.f2.minus(this.center).normalized();
+ }
+
+ public getSemiMinorAxisDirection() {
+ return this.getSemiMajorAxisDirection().orthog();
+ }
+
+ public getSemiMinorAxisVec() {
+ return this.getSemiMinorAxisDirection().times(this.ry);
+ }
+
+ public getSemiMajorAxisVec() {
+ return this.getSemiMajorAxisDirection().times(this.rx);
+ }
+
+ /**
+ * Returns the closest point on this ellipse to `point`.
+ */
+ public closestPointTo(point: Point2): Point2 {
+ // Our initial guess below requires that point ≠ center. Thus, we need
+ // an edge case.
+ if (point.eq(this.center)) {
+ const xAxisDir = this.getSemiMajorAxisDirection();
+ if (this.rx < this.ry) {
+ return this.center.plus(xAxisDir.times(this.rx));
+ }
+ return this.center.plus(xAxisDir.orthog().times(this.ry));
+ }
+
+ // Letting p be `point` and v(t) be a point on the ellipse given parameter t, observe that
+ // f(t) = ‖v(t) - p‖²
+ // is the square distance from the point on the ellipse at parameter value t and p.
+ //
+ // Thus, letting q = p - center,
+ // f(t) = ‖(rx (cos ϑ)(cos t) - ry (sin ϑ)(sin t) + center.x - pₓ,
+ // rx (sin ϑ)(cos t) + ry (sin t)(cos ϑ) + center.y - pᵧ)‖²
+ // = ‖(rx (cos ϑ)(cos t) - ry (sin ϑ)(sin t) - qₓ,
+ // rx (sin ϑ)(cos t) + ry (sin t)(cos ϑ) - qᵧ)‖²
+ // = (rx (cos ϑ)(cos t) - ry (sin ϑ)(sin t) - qₓ)²
+ // + (rx (sin ϑ)(cos t) + ry (sin t)(cos ϑ) - qᵧ)².
+ //
+ // Hence,
+ // f'(t) = 2(rx (cos ϑ)(cos t) - ry (sin ϑ)(sin t) - qₓ)
+ // (rx (cos ϑ)(-sin t) - ry (sin ϑ)(cos t))
+ // + 2(rx (sin ϑ)(cos t) + ry (sin t)(cos ϑ) - qᵧ)
+ // (rx (sin ϑ)(-sin t) + ry (cos t)(cos ϑ))
+ // = 2 Δₓ(t) (rx (cos ϑ)(-sin t) - ry (sin ϑ)(cos t))
+ // + 2 Δᵧ(t) (rx (sin ϑ)(-sin t) + ry (cos t)(cos ϑ))
+ // = 2 Δₓ(t) rx (cos ϑ)(-sin t) - 2 Δₓ(t) ry (sin ϑ)(cos t))
+ // + 2 Δᵧ(t) rx (sin ϑ)(-sin t) + 2 Δᵧ(t) ry (cos t)(cos ϑ)
+ // where Δ(t) = v(t) - p
+ //
+ // We want to find the t that minimizes the square distance. We can do this using gradient
+ // descent.
+ const loss = (t: number) => {
+ return this.at(t).minus(point).magnitudeSquared();
+ };
+
+ const lossDerivative = (t: number) => {
+ const v = this.at(t).minus(point);
+
+ // 2 vₓ(t) (rx (cos ϑ)(-sin t) - ry (sin ϑ)(cos t))
+ //+ 2 vᵧ(t) (rx (sin ϑ)(-sin t) + ry (cos t)(cos ϑ))
+ const cost = Math.cos(t);
+ const sint = Math.sin(t);
+ const cosTheta = Math.cos(this.angle);
+ const sinTheta = Math.sin(this.angle);
+ return 2 * v.x * (this.rx * cosTheta * (-sint) - this.ry * sinTheta * cost)
+ + 2 * v.y * (this.rx * sinTheta * (-sint) + this.ry * cost * cosTheta);
+ };
+
+ const [ guess1, guess2 ] =
+ this.parmeterForLineIntersection(new LineSegment2(this.center, point)) ?? [ 0, Math.PI ];
+
+ // Choose the guess that produces the smallest loss
+ const guess = loss(guess1) < loss(guess2) ? guess1 : guess2;
+
+ // Gradient descent with 10 steps, by default:
+ let steps = 10;
+ // ... but no more than 100 steps.
+ const maxSteps = 100;
+ let learningRate = 1.5;
+ let tApproximation = guess;
+ let lastLoss = loss(tApproximation);
+ for (let i = 0; i < steps && i < maxSteps; i ++) {
+ const direction = lossDerivative(tApproximation);
+ const newApproximation = tApproximation - learningRate * direction;
+
+ const newLoss = loss(tApproximation);
+ if (lastLoss >= newLoss) {
+ tApproximation = newApproximation;
+ } else {
+ // Adjust the learning rate if necessary
+ learningRate /= 10;
+
+ // Take more steps to compensate for the smaller learning rate.
+ steps += 4;
+ }
+
+ lastLoss = newLoss;
+ }
+
+ return this.at(tApproximation);
+ }
+
+ /**
+ * Returns the distance from the edge of this to `point`, or, if `point` is inside this,
+ * the negative of that distance.
+ */
+ public override signedDistance(point: Point2): number {
+ const dist = point.minus(this.closestPointTo(point)).length();
+
+ // SDF is negative for points contained in the ellipse.
+ if (this.containsPoint(point)) {
+ return -dist;
+ }
+ return dist;
+ }
+
+ public override containsPoint(point: Vec3, epsilon: number = Abstract2DShape.smallValue): boolean {
+ const distSum = this.f1.minus(point).magnitude() + this.f2.minus(point).magnitude();
+ return distSum <= 2 * this.rx + epsilon;
+ }
+
+ public hasPointOnBoundary(point: Vec3, epsilon: number = Abstract2DShape.smallValue) {
+ const distSum = this.f1.minus(point).magnitude() + this.f2.minus(point).magnitude();
+ return distSum <= 2 * this.rx + epsilon && 2 * this.rx - epsilon <= distSum;
+ }
+
+ public override toString() {
+ return `Ellipse { rx: ${this.rx}, f1: ${this.f1}, f2: ${this.f2} }`;
+ }
+}
+
+export default Ellipse;
diff --git a/packages/js-draw/src/math/shapes/EllipticalArc.test.ts b/packages/js-draw/src/math/shapes/EllipticalArc.test.ts
new file mode 100644
index 000000000..fb11fcb92
--- /dev/null
+++ b/packages/js-draw/src/math/shapes/EllipticalArc.test.ts
@@ -0,0 +1,98 @@
+import { Vec2 } from '../Vec2';
+import EllipticalArc from './EllipticalArc';
+import LineSegment2 from './LineSegment2';
+import Rect2 from './Rect2';
+
+describe('EllipticalArc', () => {
+ it('fromStartEnd should produce a line segment if rx = 0 or ry = 0', () => {
+ const startPos = Vec2.of(0, 0);
+ const endPos = Vec2.of(1, 1);
+ let rx = 0;
+ let ry = 1;
+
+ const arc1 = EllipticalArc.fromStartEnd(
+ startPos, endPos, rx, ry, Math.PI / 2, true, false
+ );
+ expect(arc1).toBeInstanceOf(LineSegment2);
+
+ rx = 1;
+ ry = 0;
+
+ const arc2 = EllipticalArc.fromStartEnd(
+ startPos, endPos, rx, ry, Math.PI / 3, true, false
+ );
+ expect(arc2).toBeInstanceOf(LineSegment2);
+ });
+
+ it('fromStartEnd should produce a circle if rx = ry', () => {
+ const startPos = Vec2.of(0, 0);
+ const endPos = Vec2.of(1, 1);
+ const rx = 1;
+ const ry = 1;
+ const angle = Math.PI / 3;
+
+ const arc1 = EllipticalArc.fromStartEnd(
+ startPos, endPos, rx, ry, angle, false, false
+ );
+ if (!(arc1 instanceof EllipticalArc)) throw new Error('Not an ellipse!');
+ expect(arc1.fullEllipse.rx).toBeCloseTo(rx);
+ expect(arc1.fullEllipse.ry).toBeCloseTo(ry);
+ expect(arc1.fullEllipse.angle).toBeCloseTo(0); // Circles have angle 0
+ expect(arc1.fullEllipse.containsPoint(startPos)).toBe(true);
+ expect(arc1.fullEllipse.containsPoint(endPos)).toBe(true);
+
+ expect(arc1.getTightBoundingBox().area).toBeGreaterThan(0);
+
+ // TODO: This part doesn't pass yet.
+ //expect(arc1.at(arc1.minParam)).objEq(startPos);
+ //expect(arc1.at(arc1.maxParam)).objEq(endPos);
+ //expect(arc1.containsPoint(startPos)).toBe(true);
+ //expect(arc1.containsPoint(endPos)).toBe(true);
+ });
+
+ describe('fromStartEnd should be able to create a half circle', () => {
+ it('...of radius 1', () => {
+ const startPos = Vec2.of(1, 0);
+ const endPos = Vec2.of(-1, 0);
+ const rx = 1;
+ const ry = 1;
+ const angle = 0;
+
+ const arc = EllipticalArc.fromStartEnd(
+ startPos, endPos, rx, ry, angle, false, false
+ );
+ if (!(arc instanceof EllipticalArc)) throw new Error('Not an arc!');
+ expect(arc.minParam).toBeCloseTo(0);
+ expect(arc.maxParam).toBeCloseTo(Math.PI);
+ expect(arc.at(arc.minParam)).objEq(startPos);
+ expect(arc.at(arc.maxParam)).objEq(endPos);
+ expect(arc.at(Math.PI / 2)).objEq(Vec2.of(0, 1));
+ expect(arc.containsPoint(arc.at(Math.PI / 2))).toBe(true);
+ expect(arc.getTightBoundingBox()).objEq(
+ new Rect2(-1, 0, 2, 1)
+ );
+ });
+
+ it('...of radius 50', () => {
+ const startPos = Vec2.of(10, 100);
+ const endPos = Vec2.of(110, 100);
+ const rx = 50;
+ const ry = 50;
+ const angle = 0;
+
+ const arc = EllipticalArc.fromStartEnd(
+ startPos, endPos, rx, ry, angle, false, true
+ );
+ if (!(arc instanceof EllipticalArc)) throw new Error('Not an arc!');
+ expect(arc.minParam).toBeCloseTo(0);
+ expect(arc.maxParam).toBeCloseTo(Math.PI);
+ expect(arc.at(arc.minParam)).objEq(endPos); // endPos corresponds to a lesser angle
+ expect(arc.at(arc.maxParam)).objEq(startPos); // startPos corresponds to a greater
+ expect(arc.at(Math.PI / 2)).objEq(Vec2.of((110 + 10) / 2, 150));
+ expect(arc.containsPoint(arc.at(Math.PI / 2))).toBe(true);
+ expect(arc.getTightBoundingBox()).objEq(
+ new Rect2(10, 100, 100, 50)
+ );
+ });
+ });
+});
\ No newline at end of file
diff --git a/packages/js-draw/src/math/shapes/EllipticalArc.ts b/packages/js-draw/src/math/shapes/EllipticalArc.ts
new file mode 100644
index 000000000..28430c516
--- /dev/null
+++ b/packages/js-draw/src/math/shapes/EllipticalArc.ts
@@ -0,0 +1,315 @@
+import { Point2, Vec2 } from '../Vec2';
+import Vec3 from '../Vec3';
+import Abstract2DShape from './Abstract2DShape';
+import Ellipse from './Ellipse';
+import LineSegment2 from './LineSegment2';
+import Rect2 from './Rect2';
+
+class EllipticalArc extends Abstract2DShape {
+ public readonly fullEllipse: Ellipse;
+ public readonly minParam: number;
+ public readonly maxParam: number;
+
+ /** True iff this arc goes from maxParam to minParam */
+ public readonly reverseSweep: boolean;
+
+ /** True iff this arc has an angle of more than π. */
+ public readonly largeArc: boolean;
+
+ public constructor(
+ // first focus
+ f1: Point2,
+
+ // second focus
+ f2: Point2,
+
+ // length of the semimajor axis
+ rx: number,
+
+ // The minimum value of the parameter used by `this.fullEllipse`.
+ minParam: number|Point2,
+
+ // The maximum value of `this.fullEllipse`'s parameter.
+ maxParam: number|Point2,
+ ) {
+ super();
+ this.fullEllipse = new Ellipse(f1, f2, rx);
+ console.log(' new Ellipse(' + f1 + ', ' + f2 + ', ' + rx + ', ' + this.fullEllipse.ry + ')');
+
+ // Convert point arguments.
+ if (typeof minParam !== 'number') {
+ minParam = this.fullEllipse.parameterForPointUnchecked(minParam);
+ }
+
+ if (typeof maxParam !== 'number') {
+ maxParam = this.fullEllipse.parameterForPointUnchecked(maxParam);
+ }
+
+ if (maxParam < minParam) {
+ const tmp = maxParam;
+ maxParam = minParam;
+ minParam = tmp;
+ this.reverseSweep = true;
+ } else {
+ this.reverseSweep = false;
+ }
+
+ this.largeArc = maxParam - minParam >= Math.PI;
+
+ if (maxParam > Math.PI * 2) {
+ maxParam -= 2 * Math.PI;
+ minParam -= 2 * Math.PI;
+ }
+
+ if (minParam < -Math.PI) {
+ maxParam += 2 * Math.PI;
+ minParam += 2 * Math.PI;
+ }
+
+ this.minParam = minParam;
+ this.maxParam = maxParam;
+
+ console.log(` with min ${this.getStartPoint()} -> ${this.getEndPoint()}`);
+ console.log(` ϑmin(${this.minParam}) ϑmax(${this.maxParam})`);
+ }
+
+ public static fromFociAndStartEnd(
+ // first focus
+ f1: Point2,
+
+ // second focus
+ f2: Point2,
+
+ startPoint: Point2,
+ endPoint: Point2,
+ ) {
+ // ‖ f1 - p ‖ + ‖ f2 - p ‖ = 2rx
+ const rx1 = (f1.minus(startPoint).length() + f2.minus(startPoint).length()) / 2;
+ const rx2 = (f1.minus(endPoint).length() + f2.minus(endPoint).length()) / 2;
+
+ // Assert rx1 is close to rx2
+ console.assert(Math.abs(rx1 - rx2) < 0.01, `${rx1} ?= ${rx2}`);
+
+ return new EllipticalArc(f1, f2, rx1, startPoint, endPoint);
+ }
+
+ /** @see https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths#arcs */
+ public static fromStartEnd(
+ startPoint: Point2,
+ endPoint: Point2,
+ rx: number,
+ ry: number,
+ majAxisRotation: number,
+ largeArcFlag: boolean,
+ sweepFlag: boolean
+ ): EllipticalArc|LineSegment2 {
+ // Reference(1): https://www.w3.org/TR/SVG/implnote.html#ArcConversionEndpointToCenter\
+ // Reference(2): https://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes
+ if (rx === 0 || ry === 0) {
+ return new LineSegment2(startPoint, endPoint);
+ }
+
+ rx = Math.abs(rx);
+ ry = Math.abs(ry);
+
+
+ // Swap axes if necessary
+ if (ry > rx) {
+ console.log('axswp');
+ majAxisRotation += Math.PI / 2;
+
+ const tmp = rx;
+ rx = ry;
+ ry = tmp;
+ }
+
+
+ // Half of the vector from startPoint to endPoint
+ const halfEndToStart = startPoint.minus(endPoint).times(0.5);
+ const phi = majAxisRotation;
+ const cosPhi = Math.cos(phi);
+ const sinPhi = Math.sin(phi);
+ const startPrime = Vec2.of(
+ cosPhi * halfEndToStart.x + sinPhi * halfEndToStart.y,
+ -sinPhi * halfEndToStart.x + cosPhi * halfEndToStart.y,
+ );
+ // TODO:
+ //Mat33.zRotation(majAxisRotation).transformVec3(halfEndToStart);
+
+ const lambda = (startPrime.x ** 2) / (rx ** 2) + (startPrime.y ** 2) / (ry ** 2);
+ if (lambda >= 1) {
+ console.log('too small, growing');
+ rx *= Math.sqrt(lambda);
+ ry *= Math.sqrt(lambda);
+ }
+
+ console.log(`El(${startPoint}, ${endPoint}, ${rx}, ${ry}, ${majAxisRotation} )`);
+ console.log(` mp ${halfEndToStart}`);
+ console.log(` s' ${startPrime}`);
+
+
+ const rx2 = rx * rx;
+ const ry2 = ry * ry;
+ const startPrime2 = Vec2.of(startPrime.x ** 2, startPrime.y ** 2);
+ let scaleNumerator = rx2 * ry2 - rx2 * startPrime2.y - ry2 * startPrime2.x;
+ if (scaleNumerator < 0) {
+ console.warn(' Below-zero numerator. Zeroing... Numerator was', scaleNumerator);
+ scaleNumerator = 0;
+ }
+
+ let centerPrimeScale = Math.sqrt(
+ scaleNumerator
+ / (rx2 * startPrime2.y + ry2 * startPrime2.y)
+ );
+ console.log(` scale: √(${scaleNumerator})/${rx2 * (startPrime.y ** 2) + ry2 * (startPrime.x ** 2)}`);
+ if (largeArcFlag === sweepFlag) {
+ centerPrimeScale = -centerPrimeScale;
+ }
+
+ const centerPrime = Vec2.of(
+ rx * startPrime.y / ry,
+ -ry * startPrime.x / rx
+ ).times(centerPrimeScale);
+ console.log(` c' ${centerPrime}`);
+
+ const startEndMidpoint = startPoint.plus(endPoint).times(0.5);
+ const center = Vec2.of(
+ cosPhi * centerPrime.x - sinPhi * centerPrime.y,
+ sinPhi * centerPrime.x + cosPhi * centerPrime.y,
+ ).plus(startEndMidpoint);
+ // Mat33.zRotation(-majAxisRotation).transformVec3(centerPrime)
+ // .plus(startEndMidpoint);
+ console.log(` c: ${center}`);
+
+ const angleBetween = (v1: Vec2, v2: Vec2) => {
+ if (v1.eq(Vec2.zero) || v2.eq(Vec2.zero)) {
+ return 0;
+ }
+
+ let result = v2.angle() - v1.angle();
+ if (result < 0) {
+ result += 2 * Math.PI;
+ }
+
+ return result;
+ };
+
+ const v1 = Vec2.of(
+ (startPrime.x - centerPrime.x) / rx,
+ (startPrime.y - centerPrime.y) / ry,
+ );
+ const v2 = Vec2.of(
+ (-startPrime.x - centerPrime.x) / rx,
+ (-startPrime.y - centerPrime.y) / ry,
+ );
+
+ const theta1 = angleBetween(Vec2.unitX, v1);
+ let sweepAngle = angleBetween(v1, v2);
+ if (sweepFlag) {
+ if (sweepAngle < 0) {
+ sweepAngle += Math.PI * 2;
+ }
+ } else {
+ if (sweepAngle > 0) {
+ sweepAngle -= Math.PI * 2;
+ }
+ }
+ const theta2 = theta1 + sweepAngle;
+ console.log(' dtheta', theta1, theta2);
+
+ // A vector pointing in the direction of the ellipse's major axis.
+ const horizontalAxis =
+ Vec2.of(Math.cos(majAxisRotation), Math.sin(majAxisRotation));
+
+ // We now must find the foci. For a point on the ellipse's vertical axis,
+ // point
+ // /|\
+ // / | \
+ // α / |ry\ α
+ // / | \
+ // f1▔▔▔▔▔▔▔▔▔f2
+ // ℓ ℓ
+ //
+ // Thus, because α + α = 2rx and ℓ² + ry² = α²,
+ const l = Math.sqrt(rx2 - ry2);
+ const f1 = center.minus(horizontalAxis.times(-l));
+ const f2 = center.minus(horizontalAxis.times(l));
+ console.log(` l: ${l}, f1: ${f1}, f2: ${f2}`);
+
+ return new EllipticalArc(f1, f2, rx, theta1, theta2);
+ }
+
+ public override signedDistance(point: Vec3): number {
+ const ellipseClosestPoint = this.fullEllipse.closestPointTo(point);
+
+ // Is the closest point on the full ellipse the same as the closest point on this arc?
+ if (this.containsPoint(ellipseClosestPoint)) {
+ return ellipseClosestPoint.minus(point).length();
+ }
+
+ // Otherwise, consider the endpoints and the point on the opposite side of the ellipse
+ const closestPointParam = this.fullEllipse.parameterForPoint(ellipseClosestPoint)!;
+ const oppositeSidePoint = this.fullEllipse.at(closestPointParam + Math.PI);
+ const endpoint1 = this.fullEllipse.at(this.minParam);
+ const endpoint2 = this.fullEllipse.at(this.maxParam);
+
+ // TODO: Are these distances exhaustive?
+ const oppositeSidePointDist = oppositeSidePoint.minus(point).length();
+ const endpoint1Dist = endpoint1.minus(point).length();
+ const endpoint2Dist = endpoint2.minus(point).length();
+
+ if (endpoint1Dist <= endpoint2Dist && endpoint1Dist <= oppositeSidePointDist) {
+ return endpoint1Dist;
+ } else if (endpoint2Dist <= oppositeSidePointDist) {
+ return endpoint2Dist;
+ } else {
+ return oppositeSidePointDist;
+ }
+ }
+
+ public override containsPoint(point: Vec3): boolean {
+ const t = this.fullEllipse.parameterForPoint(point);
+ if (t === null) {
+ return false;
+ }
+
+ const otherT = t + Math.PI * 2;
+
+ const min = this.minParam - Abstract2DShape.smallValue;
+ const max = this.maxParam + Abstract2DShape.smallValue;
+ return (t >= min && t <= max) || (otherT >= min && otherT <= max);
+ }
+
+ public override intersectsLineSegment(lineSegment: LineSegment2): Point2[] {
+ const fullEllipseIntersection = this.fullEllipse.intersectsLineSegment(lineSegment);
+ return fullEllipseIntersection.filter(point => {
+ return this.containsPoint(point);
+ });
+ }
+
+ public override getTightBoundingBox(): Rect2 {
+ const extrema = this.fullEllipse.getXYExtrema().filter(point => this.containsPoint(point));
+ const minPoint = this.fullEllipse.at(this.minParam);
+ const maxPoint = this.fullEllipse.at(this.maxParam);
+ return Rect2.bboxOf([ ...extrema, minPoint, maxPoint ]);
+ }
+
+ /**
+ * Returns the starting point for this arc. Note that this may be different
+ * from `this.at(this.minParam)` if `this.reverseSweep` is true.
+ */
+ public getStartPoint() {
+ return this.at(this.reverseSweep ? this.maxParam : this.minParam);
+ }
+
+ public getEndPoint() {
+ return this.at(this.reverseSweep ? this.minParam : this.maxParam);
+ }
+
+ /** Alias for `.fullEllipse.at`. */
+ public at(t: number) {
+ return this.fullEllipse.at(t);
+ }
+}
+
+export default EllipticalArc;
diff --git a/packages/js-draw/src/math/shapes/LineSegment2.ts b/packages/js-draw/src/math/shapes/LineSegment2.ts
index 5ad3ade2b..f4ee31922 100644
--- a/packages/js-draw/src/math/shapes/LineSegment2.ts
+++ b/packages/js-draw/src/math/shapes/LineSegment2.ts
@@ -8,6 +8,7 @@ interface IntersectionResult {
t: number;
}
+/** Represents a line segment. A `LineSegment2` is immutable. */
export default class LineSegment2 extends Abstract2DShape {
// invariant: ||direction|| = 1
@@ -26,6 +27,7 @@ export default class LineSegment2 extends Abstract2DShape {
/** The bounding box of this line segment. */
public readonly bbox;
+ /** Creates a new `LineSegment2` from its endpoints. */
public constructor(
private readonly point1: Point2,
private readonly point2: Point2
@@ -45,10 +47,13 @@ export default class LineSegment2 extends Abstract2DShape {
// Accessors to make LineSegment2 compatible with bezier-js's
// interface
+
+ /** Alias for `point1`. */
public get p1(): Point2 {
return this.point1;
}
+ /** Alias for `point2`. */
public get p2(): Point2 {
return this.point2;
}
@@ -162,6 +167,14 @@ export default class LineSegment2 extends Abstract2DShape {
return this.intersection(other) !== null;
}
+ /**
+ * Returns the points at which this line segment intersects the
+ * given line segment.
+ *
+ * Note that {@link intersects} returns *whether* this line segment intersects another
+ * line segment. This method, by contrast, returns **the point** at which the intersection
+ * occurs, if such a point exists.
+ */
public override intersectsLineSegment(lineSegment: LineSegment2) {
const intersection = this.intersection(lineSegment);
@@ -200,8 +213,11 @@ export default class LineSegment2 extends Abstract2DShape {
return this.closestPointTo(target).minus(target).magnitude();
}
+ /** Returns a copy of this line segment transformed by the given `affineTransfm`. */
public transformedBy(affineTransfm: Mat33): LineSegment2 {
- return new LineSegment2(affineTransfm.transformVec2(this.p1), affineTransfm.transformVec2(this.p2));
+ return new LineSegment2(
+ affineTransfm.transformVec2(this.p1), affineTransfm.transformVec2(this.p2)
+ );
}
/** @inheritdoc */
diff --git a/packages/js-draw/src/math/shapes/Path.fromString.test.ts b/packages/js-draw/src/math/shapes/Path.fromString.test.ts
index d48f31254..3ac7cd268 100644
--- a/packages/js-draw/src/math/shapes/Path.fromString.test.ts
+++ b/packages/js-draw/src/math/shapes/Path.fromString.test.ts
@@ -154,6 +154,47 @@ describe('Path.fromString', () => {
expect(path.startPoint).toMatchObject(Vec2.of(1, 1));
});
+ it('should handle elliptical arcs', () => {
+ const path = Path.fromString('m0 0 A9,8 90 1 0 10 10');
+ expect(path.startPoint).objEq(Vec2.zero);
+ expect(path.parts).toMatchObject([
+ {
+ kind: PathCommandType.EllipticalArcTo,
+ size: Vec2.of(9, 8),
+ majorAxisRotation: Math.PI / 2,
+ largeArcFlag: true,
+ sweepFlag: false,
+ endPoint: Vec2.of(10, 10),
+ },
+ ]);
+ });
+
+ it('should correctly handle a moveTo command followed by multiple sets of arguments', () => {
+ const path = Path.fromString(`
+ m68,163 10,10 10,11
+ M68,163 10,10
+ `);
+ expect(path.startPoint).objEq(Vec2.of(68, 163));
+ expect(path.parts).toMatchObject([
+ {
+ kind: PathCommandType.LineTo,
+ point: Vec2.of(68 + 10, 163 + 10),
+ },
+ {
+ kind: PathCommandType.LineTo,
+ point: Vec2.of(68 + 20, 163 + 21),
+ },
+ {
+ kind: PathCommandType.MoveTo,
+ point: Vec2.of(68, 163),
+ },
+ {
+ kind: PathCommandType.LineTo,
+ point: Vec2.of(10, 10),
+ },
+ ]);
+ });
+
it('should correctly handle a command followed by multiple sets of arguments', () => {
// Commands followed by multiple sets of arguments, for example,
// l 5,10 5,4 3,2,
diff --git a/packages/js-draw/src/math/shapes/Path.ts b/packages/js-draw/src/math/shapes/Path.ts
index 1e28899cc..a428d7245 100644
--- a/packages/js-draw/src/math/shapes/Path.ts
+++ b/packages/js-draw/src/math/shapes/Path.ts
@@ -9,12 +9,14 @@ import Abstract2DShape from './Abstract2DShape';
import CubicBezier from './CubicBezier';
import QuadraticBezier from './QuadraticBezier';
import PointShape2D from './PointShape2D';
+import EllipticalArc from './EllipticalArc';
export enum PathCommandType {
LineTo,
MoveTo,
CubicBezierTo,
QuadraticBezierTo,
+ EllipticalArcTo,
}
export interface CubicBezierPathCommand {
@@ -30,6 +32,16 @@ export interface QuadraticBezierPathCommand {
endPoint: Point2;
}
+// See [the W3C spec](https://www.w3.org/TR/SVG/implnote.html#ArcSyntax) for details.
+export interface EllipticalArcPathCommand {
+ kind: PathCommandType.EllipticalArcTo,
+ endPoint: Point2,
+ size: Vec2, // Vec2(semimajor axis, semiminor axis)
+ majorAxisRotation: number,
+ largeArcFlag: boolean,
+ sweepFlag: boolean
+}
+
export interface LinePathCommand {
kind: PathCommandType.LineTo;
point: Point2;
@@ -40,7 +52,12 @@ export interface MoveToPathCommand {
point: Point2;
}
-export type PathCommand = CubicBezierPathCommand | QuadraticBezierPathCommand | MoveToPathCommand | LinePathCommand;
+export type PathCommand =
+ CubicBezierPathCommand
+ | QuadraticBezierPathCommand
+ | EllipticalArcPathCommand
+ | MoveToPathCommand
+ | LinePathCommand;
interface IntersectionResult {
// @internal
@@ -53,6 +70,13 @@ interface IntersectionResult {
point: Point2;
}
+const arcFromCommand = (startPoint: Point2, part: EllipticalArcPathCommand) => {
+ return EllipticalArc.fromStartEnd(
+ startPoint, part.endPoint, part.size.x, part.size.y,
+ part.majorAxisRotation, part.largeArcFlag, part.sweepFlag
+ );
+};
+
type GeometryType = Abstract2DShape;
type GeometryArrayType = Array;
@@ -70,8 +94,10 @@ export default class Path {
// Convert into a representation of the geometry (cache for faster intersection
// calculation)
+ let lastPoint = startPoint;
for (const part of parts) {
- this.bbox = this.bbox.union(Path.computeBBoxForSegment(startPoint, part));
+ this.bbox = this.bbox.union(Path.computeBBoxForSegment(lastPoint, part));
+ lastPoint = Path.getPartEndpoint(part);
}
}
@@ -94,6 +120,7 @@ export default class Path {
let startPoint = this.startPoint;
const geometry: GeometryArrayType = [];
+ let exhaustivenessCheck: never;
for (const part of this.parts) {
switch (part.kind) {
@@ -123,6 +150,13 @@ export default class Path {
geometry.push(new PointShape2D(part.point));
startPoint = part.point;
break;
+ case PathCommandType.EllipticalArcTo:
+ geometry.push(arcFromCommand(startPoint, part));
+ startPoint = part.endPoint;
+ break;
+ default:
+ exhaustivenessCheck = part;
+ return exhaustivenessCheck;
}
}
@@ -139,6 +173,7 @@ export default class Path {
}
const points: Point2[] = [];
+ let exhaustivenessCheck: never;
for (const part of this.parts) {
switch (part.kind) {
@@ -152,6 +187,12 @@ export default class Path {
case PathCommandType.LineTo:
points.push(part.point);
break;
+ case PathCommandType.EllipticalArcTo:
+ points.push(part.endPoint);
+ break;
+ default:
+ exhaustivenessCheck = part;
+ return exhaustivenessCheck;
}
}
@@ -165,6 +206,10 @@ export default class Path {
return result;
}
+ /**
+ * **Estimates** the bounding box for the path segment given by `part`. This
+ * estimate is an overestimate.
+ */
public static computeBBoxForSegment(startPoint: Point2, part: PathCommand): Rect2 {
const points = [startPoint];
let exhaustivenessCheck: never;
@@ -179,6 +224,9 @@ export default class Path {
case PathCommandType.QuadraticBezierTo:
points.push(part.controlPoint, part.endPoint);
break;
+ case PathCommandType.EllipticalArcTo:
+ points.push(...arcFromCommand(startPoint, part).getLooseBoundingBox().corners);
+ break;
default:
exhaustivenessCheck = part;
return exhaustivenessCheck;
@@ -187,6 +235,14 @@ export default class Path {
return Rect2.bboxOf(points);
}
+ public static getPartEndpoint(part: PathCommand) {
+ if (part.kind === PathCommandType.LineTo || part.kind === PathCommandType.MoveTo) {
+ return part.point;
+ } else {
+ return part.endPoint;
+ }
+ }
+
/**
* Let `S` be a closed path a distance `strokeRadius` from this path.
*
@@ -446,7 +502,9 @@ export default class Path {
return result;
}
- private static mapPathCommand(part: PathCommand, mapping: (point: Point2)=> Point2): PathCommand {
+ private static mapPathCommand(
+ startPoint: Point2, part: PathCommand, mapping: (point: Point2)=> Point2
+ ): PathCommand {
switch (part.kind) {
case PathCommandType.MoveTo:
case PathCommandType.LineTo:
@@ -472,6 +530,42 @@ export default class Path {
break;
}
+ if (part.kind === PathCommandType.EllipticalArcTo) {
+ // TODO: Can this be done without (re)initializing this portion of the geometry?
+ // TODO: Move to EllipticalArc.ts
+ const arc = arcFromCommand(startPoint, part);
+ if (arc instanceof LineSegment2) {
+ return {
+ kind: PathCommandType.LineTo,
+ point: mapping(part.endPoint),
+ };
+ }
+
+ // TODO: This isn't correct in all cases.
+ const ellipse = arc.fullEllipse;
+ const transformedCenter = mapping(ellipse.center);
+ const transformedEnd = mapping(part.endPoint);
+ const transformedRight = mapping(ellipse.center.plus(ellipse.getSemiMajorAxisVec()));
+ const transformedUp = mapping(ellipse.center.plus(ellipse.getSemiMinorAxisVec()));
+
+ const transformedSemiMajAxis = transformedRight.minus(transformedCenter);
+ const transformedSemiMinAxis = transformedUp.minus(transformedCenter);
+ const rx = transformedSemiMajAxis.length();
+ const ry = transformedSemiMinAxis.length();
+ const majorAxisRotation = transformedSemiMajAxis.angle();
+
+ return {
+ kind: part.kind,
+ endPoint: transformedEnd,
+ size: Vec2.of(rx, ry),
+ majorAxisRotation,
+
+ // TODO: Do these need to be changed based on the mapping?
+ largeArcFlag: part.largeArcFlag,
+ sweepFlag: part.sweepFlag,
+ };
+ }
+
const exhaustivenessCheck: never = part;
return exhaustivenessCheck;
}
@@ -479,9 +573,11 @@ export default class Path {
public mapPoints(mapping: (point: Point2)=>Point2): Path {
const startPoint = mapping(this.startPoint);
const newParts: PathCommand[] = [];
+ let prevPoint = this.startPoint;
for (const part of this.parts) {
- newParts.push(Path.mapPathCommand(part, mapping));
+ newParts.push(Path.mapPathCommand(prevPoint, part, mapping));
+ prevPoint = Path.getPartEndpoint(part);
}
return new Path(startPoint, newParts);
@@ -516,11 +612,7 @@ export default class Path {
return this.startPoint;
}
const lastPart = this.parts[this.parts.length - 1];
- if (lastPart.kind === PathCommandType.QuadraticBezierTo || lastPart.kind === PathCommandType.CubicBezierTo) {
- return lastPart.endPoint;
- } else {
- return lastPart.point;
- }
+ return Path.getPartEndpoint(lastPart);
}
public roughlyIntersects(rect: Rect2, strokeWidth: number = 0) {
@@ -541,12 +633,7 @@ export default class Path {
let startPoint = this.startPoint;
for (const part of this.parts) {
const bbox = Path.computeBBoxForSegment(startPoint, part).grownBy(strokeWidth);
-
- if (part.kind === PathCommandType.LineTo || part.kind === PathCommandType.MoveTo) {
- startPoint = part.point;
- } else {
- startPoint = part.endPoint;
- }
+ startPoint = Path.getPartEndpoint(part);
if (rect.intersects(bbox)) {
return true;
@@ -799,6 +886,19 @@ export default class Path {
}
};
+ const addArcCommand = (command: EllipticalArcPathCommand) => {
+ const endX = toRoundedString(command.endPoint.x);
+ const endY = toRoundedString(command.endPoint.y);
+ const rx = toRoundedString(command.size.x);
+ const ry = toRoundedString(command.size.y);
+ const xRotation = toRoundedString(command.majorAxisRotation * 180 / Math.PI);
+ const largeArcFlag = command.largeArcFlag ? '1' : '0';
+ const sweepFlag = command.sweepFlag ? '1' : '0';
+
+ result.push(`A${rx},${ry} ${xRotation} ${largeArcFlag} ${sweepFlag} ${endX},${endY}`);
+ prevPoint = command.endPoint;
+ };
+
// Don't add two moveTos in a row (this can happen if
// the start point corresponds to a moveTo _and_ the first command is
// also a moveTo)
@@ -823,6 +923,9 @@ export default class Path {
case PathCommandType.QuadraticBezierTo:
addCommand('Q', part.controlPoint, part.endPoint);
break;
+ case PathCommandType.EllipticalArcTo:
+ addArcCommand(part);
+ break;
default:
exhaustivenessCheck = part;
return exhaustivenessCheck;
@@ -889,14 +992,35 @@ export default class Path {
endPoint,
});
};
+ const ellipticalArcTo = (
+ rx: number, ry: number,
+
+ // Degrees
+ majAxisRotation: number,
+ largeArcFlag: number, sweepFlag: number,
+ endPoint: Point2,
+ ) => {
+ // Degrees -> radians
+ majAxisRotation = majAxisRotation * Math.PI / 180;
+
+ commands.push({
+ kind: PathCommandType.EllipticalArcTo,
+ size: Vec2.of(rx, ry),
+ majorAxisRotation: majAxisRotation,
+ largeArcFlag: largeArcFlag > 0.5,
+ sweepFlag: sweepFlag > 0.5,
+ endPoint,
+ });
+ };
const commandArgCounts: Record = {
- 'm': 1,
- 'l': 1,
- 'c': 3,
- 'q': 2,
+ 'm': 2,
+ 'l': 2,
+ 'c': 6,
+ 'a': 7,
+ 'q': 4,
'z': 0,
- 'h': 1,
- 'v': 1,
+ 'h': 2,
+ 'v': 2,
};
// Each command: Command character followed by anything that isn't a command character
@@ -919,14 +1043,20 @@ export default class Path {
return accumualtor;
}, []);
- let numericArgs = argParts.map(arg => parseFloat(arg));
+ // All arguments, converted to numbers
+ const numericArgs = argParts.map(arg => parseFloat(arg));
+
+ let pointArgCoordinates = numericArgs;
+ const nonPointArgs: number[] = [];
let commandChar = current[1].toLowerCase();
let uppercaseCommand = current[1] !== commandChar;
+ let nonPointArgsPerSet = 0;
+
// Convert commands that don't take points into commands that do.
if (commandChar === 'v' || commandChar === 'h') {
- numericArgs = numericArgs.reduce((accumulator: number[], current: number): number[] => {
+ pointArgCoordinates = pointArgCoordinates.reduce((accumulator: number[], current: number): number[] => {
if (commandChar === 'v') {
return accumulator.concat(uppercaseCommand ? lastPos.x : 0, current);
} else {
@@ -936,7 +1066,7 @@ export default class Path {
commandChar = 'l';
} else if (commandChar === 'z') {
if (firstPos) {
- numericArgs = [ firstPos.x, firstPos.y ];
+ pointArgCoordinates = [ firstPos.x, firstPos.y ];
firstPos = lastPos;
} else {
continue;
@@ -947,9 +1077,41 @@ export default class Path {
commandChar = 'l';
}
+ // Expected number of arguments per set.
+ const expectedArgCount: number = commandArgCounts[commandChar] ?? 0;
+
+ // Some commands don't take only point arguments and thus need
+ // special processing.
+ if (commandChar === 'a') {
+ pointArgCoordinates = [];
+ nonPointArgsPerSet = 5;
+ for (let i = 0; i < numericArgs.length; i++) {
+ // 0 1 2 3 4 5 6
+ // rx ry majAxisRotation largeArcFlag sweepFlag x y
+ if (i % expectedArgCount === 5 || i % expectedArgCount === 6) {
+ pointArgCoordinates.push(numericArgs[i]);
+ } else {
+ nonPointArgs.push(numericArgs[i]);
+ }
+ }
+ }
+
+ const actualArgCount = pointArgCoordinates.length + nonPointArgs.length;
+ if (actualArgCount % expectedArgCount !== 0) {
+ throw new Error([
+ `Incorrect number of arguments: got ${JSON.stringify(numericArgs)}`,
+ `with a length of ${actualArgCount} ≠ ${expectedArgCount}k, k ∈ ℤ.`,
+ `The number of arguments to ${commandChar} must be a multiple of ${expectedArgCount}!`,
+ `Command: ${current[0]}`,
+ ].join('\n'));
+ }
- const commandArgCount: number = commandArgCounts[commandChar] ?? 0;
- const allArgs = numericArgs.reduce((
+ // Because SVG argument lists can be repeated without repeating letters,
+ // we can have multiple sets of arguments for the same command;
+ const numArgSets = actualArgCount / expectedArgCount;
+ const pointArgsPerSet = (expectedArgCount - nonPointArgsPerSet) / 2;
+
+ const pointArgs = pointArgCoordinates.reduce((
accumulator: Point2[], current, index, parts
): Point2[] => {
if (index % 2 !== 0) {
@@ -969,40 +1131,48 @@ export default class Path {
newPos = lastPos.plus(coordinate);
}
- if ((index + 1) % commandArgCount === 0) {
+ // If the last point arg in the set,
+ if ((index + 1) % pointArgsPerSet === 0) {
lastPos = newPos;
}
return newPos;
});
- if (allArgs.length % commandArgCount !== 0) {
- throw new Error([
- `Incorrect number of arguments: got ${JSON.stringify(allArgs)} with a length of ${allArgs.length} ≠ ${commandArgCount}k, k ∈ ℤ.`,
- `The number of arguments to ${commandChar} must be a multiple of ${commandArgCount}!`,
- `Command: ${current[0]}`,
- ].join('\n'));
- }
- for (let argPos = 0; argPos < allArgs.length; argPos += commandArgCount) {
- const args = allArgs.slice(argPos, argPos + commandArgCount);
+ for (let argSetIdx = 0; argSetIdx < numArgSets; argSetIdx ++) {
+ const points = pointArgs.slice(
+ argSetIdx * pointArgsPerSet,
+ (argSetIdx + 1) * pointArgsPerSet
+ );
+ const nonPoints = nonPointArgsPerSet === 0 ? [] : nonPointArgs.slice(
+ argSetIdx * nonPointArgsPerSet,
+ (argSetIdx + 1) * nonPointArgsPerSet
+ );
switch (commandChar.toLowerCase()) {
case 'm':
- if (argPos === 0) {
- moveTo(args[0]);
+ if (argSetIdx === 0) {
+ moveTo(points[0]);
} else {
- lineTo(args[0]);
+ lineTo(points[0]);
}
break;
case 'l':
- lineTo(args[0]);
+ lineTo(points[0]);
break;
case 'c':
- cubicBezierTo(args[0], args[1], args[2]);
+ cubicBezierTo(points[0], points[1], points[2]);
break;
case 'q':
- quadraticBeierTo(args[0], args[1]);
+ quadraticBeierTo(points[0], points[1]);
+ break;
+ case 'a':
+ ellipticalArcTo(
+ nonPoints[0], nonPoints[1],
+ nonPoints[2], nonPoints[3], nonPoints[4],
+ points[0],
+ );
break;
default:
throw new Error(`Unknown path command ${commandChar}`);
@@ -1011,10 +1181,10 @@ export default class Path {
isFirstCommand = false;
}
- if (allArgs.length > 0) {
- firstPos ??= allArgs[0];
+ if (pointArgs.length > 0) {
+ firstPos ??= pointArgs[0];
startPos ??= firstPos;
- lastPos = allArgs[allArgs.length - 1];
+ lastPos = pointArgs[pointArgs.length - 1];
}
}
diff --git a/packages/js-draw/src/rendering/renderers/AbstractRenderer.ts b/packages/js-draw/src/rendering/renderers/AbstractRenderer.ts
index 5aa1563ac..877f086cf 100644
--- a/packages/js-draw/src/rendering/renderers/AbstractRenderer.ts
+++ b/packages/js-draw/src/rendering/renderers/AbstractRenderer.ts
@@ -1,4 +1,6 @@
import Color4 from '../../Color4';
+import EllipticalArc from '../../math/shapes/EllipticalArc';
+import LineSegment2 from '../../math/shapes/LineSegment2';
import { LoadSaveDataTable } from '../../components/AbstractComponent';
import Mat33 from '../../math/Mat33';
import Path, { PathCommand, PathCommandType } from '../../math/shapes/Path';
@@ -71,8 +73,9 @@ export default abstract class AbstractRenderer {
public setDraftMode(_draftMode: boolean) { }
protected objectLevel: number = 0;
+ protected pathLastPoint: Point2 = Vec2.zero;
private currentPaths: RenderablePathSpec[]|null = null;
- private flushPath() {
+ private flushPath(): void {
if (!this.currentPaths) {
return;
}
@@ -80,6 +83,7 @@ export default abstract class AbstractRenderer {
let lastStyle: RenderingStyle|null = null;
for (const path of this.currentPaths) {
const { startPoint, commands, style } = path;
+ this.pathLastPoint = startPoint;
if (!lastStyle || !stylesEqual(lastStyle, style)) {
if (lastStyle) {
@@ -95,16 +99,29 @@ export default abstract class AbstractRenderer {
for (const command of commands) {
if (command.kind === PathCommandType.LineTo) {
this.lineTo(command.point);
+ this.pathLastPoint = command.point;
} else if (command.kind === PathCommandType.MoveTo) {
this.moveTo(command.point);
+ this.pathLastPoint = command.point;
} else if (command.kind === PathCommandType.CubicBezierTo) {
this.traceCubicBezierCurve(
command.controlPoint1, command.controlPoint2, command.endPoint
);
+ this.pathLastPoint = command.endPoint;
} else if (command.kind === PathCommandType.QuadraticBezierTo) {
this.traceQuadraticBezierCurve(
command.controlPoint, command.endPoint
);
+ this.pathLastPoint = command.endPoint;
+ } else if (command.kind === PathCommandType.EllipticalArcTo) {
+ this.traceEllipticalArc(
+ command.size, command.majorAxisRotation, command.largeArcFlag,
+ command.sweepFlag, command.endPoint,
+ );
+ this.pathLastPoint = command.endPoint;
+ } else {
+ const exhaustivenessCheck: never = command;
+ return exhaustivenessCheck;
}
}
}
@@ -147,6 +164,42 @@ export default abstract class AbstractRenderer {
this.drawPath(path.toRenderable({ fill }));
}
+ /**
+ * Draws an approximate elliptical arc. It is recommended that subclasses override this method.
+ *
+ * Notice the order of the parameters (the first parameter is `size`, not `startPoint`).
+ */
+ protected traceEllipticalArc(
+ size: Vec2,
+ majorAxisRotation: number,
+ largeArcFlag: boolean,
+ sweepFlag: boolean,
+ endPoint: Point2,
+ ): void {
+ const startPoint = this.pathLastPoint;
+ const ellipse = EllipticalArc.fromStartEnd(
+ startPoint, endPoint, size.x, size.y, majorAxisRotation, largeArcFlag, sweepFlag
+ );
+
+ if (ellipse instanceof LineSegment2) {
+ this.lineTo(endPoint);
+ return;
+ }
+
+ const step = 0.1;
+ if (ellipse.reverseSweep) {
+ for (let t = ellipse.maxParam; t >= ellipse.minParam; t -= step) {
+ this.lineTo(ellipse.at(t));
+ }
+ this.lineTo(ellipse.at(ellipse.minParam));
+ } else {
+ for (let t = ellipse.minParam; t <= ellipse.maxParam; t += step) {
+ this.lineTo(ellipse.at(t));
+ }
+ this.lineTo(ellipse.at(ellipse.maxParam));
+ }
+ }
+
/**
* This should be called whenever a new object is being drawn.
*
diff --git a/packages/js-draw/src/rendering/renderers/SVGRenderer.ts b/packages/js-draw/src/rendering/renderers/SVGRenderer.ts
index 0b9ebadf1..7ca9fa04b 100644
--- a/packages/js-draw/src/rendering/renderers/SVGRenderer.ts
+++ b/packages/js-draw/src/rendering/renderers/SVGRenderer.ts
@@ -323,6 +323,10 @@ export default class SVGRenderer extends AbstractRenderer {
_controlPoint1: Point2, _controlPoint2: Point2, _endPoint: Point2
) { this.unimplementedMessage(); }
protected traceQuadraticBezierCurve(_controlPoint: Point2, _endPoint: Point2) { this.unimplementedMessage(); }
+ protected override traceEllipticalArc(
+ _size: Vec2, _majorAxisRotation: number, _largeArcFlag: boolean, _sweepFlag: boolean,
+ _endPoint: Point2,
+ ) { this.unimplementedMessage(); }
public drawPoints(...points: Point2[]) {
points.map(point => {