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 => {