/
HobbyPath.ts
313 lines (296 loc) · 10.4 KB
/
HobbyPath.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
/**
* @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