UNPKG

plotboilerplate

Version:

A simple javascript plotting boilerplate for 2d stuff.

290 lines (277 loc) 11 kB
/** * @classdesc A HobbyCurve/HobbyPath calculation class: compute a set of optimal * cubic Bézier curves from a sequence of vertices. * * This Hobby curve (path) implementation was strongly inspired by * the one by Prof. Dr. Edmund Weitz: * Here's the website: * http://weitz.de/hobby/ * * @date 2020-04-07 * @author Transformed to a JS-class by Ikaros Kappler * @modified 2020-08-19 Ported from vanilla JS to TypeScript. * @version 1.0.1 * * @file HobbyPath * @public **/ import { CubicBezierCurve } from "../../CubicBezierCurve"; import { Vertex } from "../../Vertex"; interface IControlPoints { startControlPoints: Array<Vertex>; endControlPoints: Array<Vertex>; } /** * @classdesc A HobbyCurve/HobbyPath calculation class: compute a set of optimal * cubic Bézier curves from a sequence of vertices. * * @requires CubicBezierCurve * @requires Vertex */ export class HobbyPath { /** * @member {Array<Vertex>} vertices * @memberof HobbyPath * @type {Array<Vertex>} * @instance **/ private vertices: Array<Vertex>; /** * @constructor * @name HobbyPath * @param {Array<Vertex>=} vertices? - An optional array of vertices to initialize the path with. **/ constructor(vertices?: Array<Vertex>) { this.vertices = vertices ? vertices : []; } /** * Add a new point to the end of the vertex sequence. * * @name addPoint * @memberof HobbyPath * @instance * @param {Vertex} p - The vertex (point) to add. **/ addPoint(p: Vertex): void { this.vertices.push(p); } /** * Generate a sequence of cubic Bézier curves from the point set. * * @name generateCurve * @memberof HobbyPath * @instance * @param {boolean=} circular - Specify if the path should be closed. * @param {number=0} omega - (default=0) An optional tension parameter. * @return Array<CubicBezierCurve> **/ generateCurve(circular?: boolean, omega?: number): Array<CubicBezierCurve> { let n: number = this.vertices.length; if (n > 1) { if (n == 2) { // for two points, just draw a straight line return [new CubicBezierCurve(this.vertices[0], this.vertices[1], this.vertices[0], this.vertices[1])]; } else { const curves: Array<CubicBezierCurve> = []; let controlPoints = this.hobbyControls(circular, omega); for (let i = 0; i < n - (circular ? 0 : 1); i++) { // if i is n-1, the "next" point is the first one let j: number = (i + 1) % n; // Use a succ function here? curves.push( new CubicBezierCurve( this.vertices[i], this.vertices[j], controlPoints.startControlPoints[i], controlPoints.endControlPoints[i] ) ); } return curves; } } else { return []; } } /** * Computes the control point coordinates for a Hobby curve through * the points given. * * @name hobbyControls * @memberof HobbyPath * @instance * @param {boolean} circular - If true, then the path will be closed. * @param {number=0} omega - The 'curl' or the path. * @return {IControlPoints} An object with two members: startControlPoints and endControlPoints (Array<Vertex>). **/ private hobbyControls(circular?: boolean, omega?: number): IControlPoints { // This is a version that works for both, closed and non-closed paths. if (typeof omega === "undefined") omega = 0; let n: number = this.vertices.length - (circular ? 0 : 1); let D: Array<number> = new Array<number>(n); let ds: Array<Vertex> = new Array<Vertex>(n); var succ = (i: number): number => { return circular ? (i + 1) % n : i + 1; }; var pred = (i: number): number => { return circular ? (i + n - 1) % n : i - 1; }; for (let i = 0; i < n; i++) { // the "next" point in a modular way let j: number = succ(i); ds[i] = this.vertices[i].difference(this.vertices[j]); D[i] = Math.sqrt(ds[i].x * ds[i].x + ds[i].y * ds[i].y); } let gamma: Array<number> = new Array(n + (circular ? 0 : 1)); for (let i = circular ? 0 : 1; i < n; i++) { // the "previous" point in a modular way let k: number = pred(i); let sin = ds[k].y / D[k]; let cos = ds[k].x / D[k]; let vec = HobbyPath.utils.rotate(ds[i], -sin, cos); gamma[i] = Math.atan2(vec.y, vec.x); } if (!circular) gamma[n] = 0; let a: Array<number> = new Array(n + (circular ? 0 : 1)); let b: Array<number> = new Array(n + (circular ? 0 : 1)); let c: Array<number> = new Array(n + (circular ? 0 : 1)); let d: Array<number> = new Array(n + (circular ? 0 : 1)); for (let i = circular ? 0 : 1; i < n; i++) { // j is the "next" point, k the "previous" one let j: number = succ(i); let k: number = pred(i); // see video for the equations a[i] = 1 / D[k]; b[i] = (2 * D[k] + 2 * D[i]) / (D[k] * D[i]); c[i] = 1 / D[i]; d[i] = -(2 * gamma[i] * D[i] + gamma[j] * D[k]) / (D[k] * D[i]); } // make matrix tridiagonal in preparation for the "sherman" function var alpha: Array<number>; var beta: Array<number>; if (circular) { let s: number = a[0] * omega; // Use omega here? a[0] = 0; let t: number = c[n - 1] * omega; // Use omega here? c[n - 1] = 0; alpha = HobbyPath.utils.sherman(a, b, c, d, s, t); beta = new Array<number>(n); for (let i = 0; i < n - (circular ? 0 : 1); i++) { // "next" point let j: number = succ(i); beta[i] = -gamma[j] - alpha[j]; } } else { // see the Jackowski article for the following values; the result // will be that the curvature at the first point is identical to the // curvature at the second point (and likewise for the last and // second-to-last) b[0] = 2 + omega; c[0] = 2 * omega + 1; d[0] = -c[0] * gamma[1]; a[n] = 2 * omega + 1; b[n] = 2 + omega; d[n] = 0; // solve system for the angles called "alpha" in the video alpha = HobbyPath.utils.thomas(a, b, c, d); // compute "beta" angles from "alpha" angles beta = new Array<number>(n); for (let i = 0; i < n - 1; i++) beta[i] = -gamma[i + 1] - alpha[i + 1]; // again, see Jackowski article beta[n - 1] = -alpha[n]; } let startControlPoints = new Array<Vertex>(n); let endControlPoints = new Array<Vertex>(n); for (let i = 0; i < n; i++) { let j: number = succ(i); let a: number = (HobbyPath.utils.rho(alpha[i], beta[i]) * D[i]) / 3; let b: number = (HobbyPath.utils.rho(beta[i], alpha[i]) * D[i]) / 3; let v: Vertex = HobbyPath.utils.normalize(HobbyPath.utils.rotateAngle(ds[i], alpha[i])); startControlPoints[i] = new Vertex(this.vertices[i].x + a * v.x, this.vertices[i].y + a * v.y); v = HobbyPath.utils.normalize(HobbyPath.utils.rotateAngle(ds[i], -beta[i])); endControlPoints[i] = new Vertex(this.vertices[j].x - b * v.x, this.vertices[j].y - b * v.y); } return { startControlPoints: startControlPoints, endControlPoints: endControlPoints }; } static utils = { // rotates a vector [x, y] about an angle; the angle is implicitly // determined by its sine and cosine rotate: (vert: Vertex, sin: number, cos: number): Vertex => { return new Vertex(vert.x * cos - vert.y * sin, vert.x * sin + vert.y * cos); }, // rotates a vector [x, y] about the angle alpha rotateAngle: (vert: Vertex, alpha: number): Vertex => { return HobbyPath.utils.rotate(vert, Math.sin(alpha), Math.cos(alpha)); }, // returns a normalized version of the vector normalize: (vec: Vertex): Vertex => { let n = Math.hypot(vec.x, vec.y); if (n == 0) return new Vertex(0, 0); else return new Vertex(vec.x / n, vec.y / n); // TODO: do in-place }, // the "velocity function" (also called rho in the video); a and b are // the angles alpha and beta, the return value is the distance between // a control point and its neighboring point; to compute sigma(a,b) // we'll simply use rho(b,a) rho: (a: number, b: number): number => { // see video for formula let sa = Math.sin(a); let sb = Math.sin(b); let ca = Math.cos(a); let cb = Math.cos(b); let s5 = Math.sqrt(5); let num = 4 + Math.sqrt(8) * (sa - sb / 16) * (sb - sa / 16) * (ca - cb); let den = 2 + (s5 - 1) * ca + (3 - s5) * cb; return num / den; }, // Implements the Thomas algorithm for a tridiagonal system with i-th // row a[i]x[i-1] + b[i]x[i] + c[i]x[i+1] = d[i] starting with row // i=0, ending with row i=n-1 and with a[0] = c[n-1] = 0. Returns the // values x[i] as an array. thomas: (a: Array<number>, b: Array<number>, c: Array<number>, d: Array<number>): Array<number> => { let n: number = a.length; let cc: Array<number> = new Array<number>(n); let dd: Array<number> = new Array<number>(n); // forward sweep cc[0] = c[0] / b[0]; dd[0] = d[0] / b[0]; for (let i = 1; i < n; i++) { let den: number = b[i] - cc[i - 1] * a[i]; cc[i] = c[i] / den; dd[i] = (d[i] - dd[i - 1] * a[i]) / den; } let x: Array<number> = new Array<number>(n); // back substitution x[n - 1] = dd[n - 1]; for (let i = n - 2; i >= 0; i--) x[i] = dd[i] - cc[i] * x[i + 1]; return x; }, // Solves an "almost" tridiagonal linear system with i-th row // a[i]x[i-1] + b[i]x[i] + c[i]x[i+1] = d[i] starting with row i=0, // ending with row i=n-1 and with a[0] = c[n-1] = 0. Returns the // values x[i] as an array. The system is not really tridiagonal // because the 0-th row is b[0]x[0] + c[0]x[1] + sx[n-1] = d[0] and // row n-1 is tx[0] + a[n-1]x[n-2] + b[n-1]x[n-1] = d[n-1]. The // Sherman-Morrison-Woodbury formula is used so that the function // "thomas" can be called to solve the system. sherman: (a: Array<number>, b: Array<number>, c: Array<number>, d: Array<number>, s: number, t: number): Array<number> => { const n: number = a.length; const u: Array<number> = new Array<number>(n); u.fill(0, 1, n - 1); u[0] = 1; u[n - 1] = 1; let v: Array<number> = new Array<number>(n); v.fill(0, 1, n - 1); v[0] = t; v[n - 1] = s; b[0] -= t; b[n - 1] -= s; // this would be more efficient if computed in parallel, but hey... const Td: Array<number> = HobbyPath.utils.thomas(a, b, c, d); const Tu: Array<number> = HobbyPath.utils.thomas(a, b, c, u); const factor: number = (t * Td[0] + s * Td[n - 1]) / (1 + t * Tu[0] + s * Tu[n - 1]); const x = new Array(n); for (let i = 0; i < n; i++) x[i] = Td[i] - factor * Tu[i]; return x; } }; } // END class