/
path2d.js
executable file
·464 lines (395 loc) · 18.6 KB
/
path2d.js
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
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
import pool from "./../system/pooling.js";
import { TAU } from "./../math/math.js";
import earcut from "earcut";
import { endpointToCenterParameterization } from "./toarccanvas.js";
/**
* @classdesc
* a simplified path2d implementation, supporting only one path
*/
export default class Path2D {
constructor(svgPath) {
/**
* the points defining the current path
* @type {Point[]}
*/
this.points = [];
/**
* space between interpolated points for quadratic and bezier curve approx. in pixels.
* @type {number}
* @default 5
*/
this.arcResolution = 5;
/* @ignore */
this.vertices = [];
/* @ignore */
this.startPoint = pool.pull("Point");
/* @ignore */
this.isDirty = false;
if (typeof svgPath === "string") {
this.parseSVGPath(svgPath);
}
}
/**
* Parses an SVG path string and adds the points to the current path.
* @param {string} svgPath - The SVG path string to parse.
*/
parseSVGPath(svgPath) {
// Split path into commands and coordinates
const pathCommands = svgPath.match(/([a-df-z])[^a-df-z]*/gi);
const points = this.points;
const startPoint = this.startPoint;
let lastPoint = startPoint;
this.beginPath();
// Process each command and corresponding coordinates
for (let i = 0; i < pathCommands.length; i++) {
const pathCommand = pathCommands[i];
const command = pathCommand[0].toUpperCase();
const coordinates = pathCommand.slice(1).trim().split(/[\s,]+/).map(parseFloat);
switch (command) {
case "A": {
// A command takes 5 coordinates
const p = endpointToCenterParameterization(...coordinates);
this.arc(p.x, p.y, p.radiusX, p.radiusY, p.rotation, p.startAngle, p.endAngle, p.applyanticlockwise);
}
break;
case "H":
// H take 1 coordinate
lastPoint = points.length === 0 ? startPoint : points[points.length - 1];
this.lineTo(lastPoint.x + coordinates[0], lastPoint.y);
break;
case "V":
// V take 1 coordinate
lastPoint = points.length === 0 ? startPoint : points[points.length - 1];
this.lineTo(lastPoint.x, lastPoint.y + coordinates[0]);
break;
case "M":
// M takes 2 coordinates
this.moveTo(...coordinates);
break;
case "L":
// L takes 2 coordinates
this.lineTo(...coordinates);
break;
case "Q":
// Q takes 4 coordinates
this.quadraticCurveTo(...coordinates);
break;
case "C":
// C takes 6 coordinates
this.bezierCurveTo(...coordinates);
break;
case "Z":
this.closePath();
break;
default:
console.warn("Unsupported command:", command);
break;
}
}
}
/**
* begin a new path
*/
beginPath() {
// empty the cache and recycle all vectors
this.points.forEach((point) => {
pool.push(point);
});
this.isDirty = true;
this.points.length = 0;
this.startPoint.set(0, 0);
}
/**
* causes the point of the pen to move back to the start of the current path.
* It tries to draw a straight line from the current point to the start.
* If the shape has already been closed or has only one point, this function does nothing.
*/
closePath() {
let points = this.points;
if (points.length > 0) {
let firstPoint = points[0];
if (!firstPoint.equals(points[points.length - 1])) {
this.lineTo(firstPoint.x, firstPoint.y);
}
this.isDirty = true;
}
}
/**
* triangulate the shape defined by this path into an array of triangles
* @returns {Point[]} an array of vertices representing the triangulated path or shape
*/
triangulatePath() {
let vertices = this.vertices;
if (this.isDirty) {
let points = this.points;
let indices = earcut(points.flatMap(p => [p.x, p.y]));
let indicesLength = indices.length;
// pre-allocate vertices if necessary
while (vertices.length < indicesLength) {
vertices.push(pool.pull("Point"));
}
// calculate all vertices
for (let i = 0; i < indicesLength; i++) {
let point = points[indices[i]];
vertices[i].set(point.x, point.y);
}
// recycle overhead from a previous triangulation
while (vertices.length > indicesLength) {
pool.push(vertices[vertices.length - 1]);
vertices.length -= 1;
}
this.isDirty = false;
}
return vertices;
}
/**
* moves the starting point of the current path to the (x, y) coordinates.
* @param {number} x - the x-axis (horizontal) coordinate of the point.
* @param {number} y - the y-axis (vertical) coordinate of the point.
*/
moveTo(x, y) {
this.startPoint.set(x, y);
this.isDirty = true;
}
/**
* connects the last point in the current path to the (x, y) coordinates with a straight line.
* @param {number} x - the x-axis coordinate of the line's end point.
* @param {number} y - the y-axis coordinate of the line's end point.
*/
lineTo(x, y) {
let points = this.points;
let startPoint = this.startPoint;
let lastPoint = points.length === 0 ? startPoint : points[points.length - 1];
if (!startPoint.equals(lastPoint)) {
points.push(pool.pull("Point", startPoint.x, startPoint.y));
} else {
points.push(pool.pull("Point", lastPoint.x, lastPoint.y));
}
points.push(pool.pull("Point", x, y));
startPoint.x = x;
startPoint.y = y;
this.isDirty = true;
}
/**
* adds an arc to the current path which is centered at (x, y) position with the given radius,
* starting at startAngle and ending at endAngle going in the given direction by counterclockwise (defaulting to clockwise).
* @param {number} x - the horizontal coordinate of the arc's center.
* @param {number} y - the vertical coordinate of the arc's center.
* @param {number} radius - the arc's radius. Must be positive.
* @param {number} startAngle - the angle at which the arc starts in radians, measured from the positive x-axis.
* @param {number} endAngle - the angle at which the arc ends in radians, measured from the positive x-axis.
* @param {boolean} [anticlockwise=false] - an optional boolean value. If true, draws the arc counter-clockwise between the start and end angles.
*/
arc(x, y, radius, startAngle, endAngle, anticlockwise = false) {
// based on from https://github.com/karellodewijk/canvas-webgl/blob/master/canvas-webgl.js
//bring angles all in [0, 2*PI] range
if (startAngle === endAngle) return;
const fullCircle = anticlockwise ? Math.abs(startAngle - endAngle) >= (TAU) : Math.abs(endAngle - startAngle) >= (TAU);
startAngle = startAngle % (TAU);
endAngle = endAngle % (TAU);
if (startAngle < 0) startAngle += TAU;
if (endAngle < 0) endAngle += TAU;
if (startAngle >= endAngle) {
endAngle += TAU;
}
let diff = endAngle - startAngle;
let direction = 1;
if (anticlockwise) {
direction = -1;
diff = TAU - diff;
}
if (fullCircle) diff = TAU;
const length = diff * radius;
const nr_of_interpolation_points = length / this.arcResolution;
const dangle = diff / nr_of_interpolation_points;
const angleStep = dangle * direction;
this.moveTo(x + radius * Math.cos(startAngle), y + radius * Math.sin(startAngle));
let angle = startAngle;
for (let j = 0; j < nr_of_interpolation_points; j++) {
this.lineTo(x + radius * Math.cos(angle), y + radius * Math.sin(angle));
angle += angleStep;
}
this.lineTo(x + radius * Math.cos(endAngle), y + radius * Math.sin(endAngle));
this.isDirty = true;
}
/**
* adds a circular arc to the path with the given control points and radius, connected to the previous point by a straight line.
* @param {number} x1 - the x-axis coordinate of the first control point.
* @param {number} y1 - the y-axis coordinate of the first control point.
* @param {number} x2 - the x-axis coordinate of the second control point.
* @param {number} y2 - the y-axis coordinate of the second control point.
* @param {number} radius - the arc's radius. Must be positive.
*/
arcTo(x1, y1, x2, y2, radius) {
let points = this.points;
let startPoint = this.startPoint;
let lastPoint = points.length === 0 ? startPoint : points[points.length - 1];
// based on from https://github.com/karellodewijk/canvas-webgl/blob/master/canvas-webgl.js
let x0 = lastPoint.x, y0 = lastPoint.y;
//a = -incoming vector, b = outgoing vector to x1, y1
let a0 = x0 - x1, a1 = y0 - y1;
let b0 = x2 - x1, b1 = y2 - y1;
//normalize
let l_a = Math.sqrt(Math.pow(a0, 2) + Math.pow(a1, 2));
let l_b = Math.sqrt(Math.pow(b0, 2) + Math.pow(b1, 2));
a0 /= l_a; a1 /= l_a; b0 /= l_b; b1 /= l_b;
let angle = Math.atan2(a1, a0) - Math.atan2(b1, b0);
//work out tangent points using tan(θ) = opposite / adjacent; angle/2 because hypotenuse is the bisection of a,b
let tan_angle_div2 = Math.tan(angle / 2);
let adj_l = (radius / tan_angle_div2);
let tangent1_pointx = x1 + a0 * adj_l, tangent1_pointy = y1 + a1 * adj_l;
let tangent2_pointx = x1 + b0 * adj_l, tangent2_pointy = y1 + b1 * adj_l;
this.moveTo(tangent1_pointx, tangent1_pointy);
let bisec0 = (a0 + b0) / 2.0, bisec1 = (a1 + b1) / 2.0;
let bisec_l = Math.sqrt(Math.pow(bisec0, 2) + Math.pow(bisec1, 2));
bisec0 /= bisec_l; bisec1 /= bisec_l;
let hyp_l = Math.sqrt(Math.pow(radius, 2) + Math.pow(adj_l, 2));
let centerx = x1 + hyp_l * bisec0, centery = y1 + hyp_l * bisec1;
let startAngle = Math.atan2(tangent1_pointy - centery, tangent1_pointx - centerx);
let endAngle = Math.atan2(tangent2_pointy - centery, tangent2_pointx - centerx);
this.arc(centerx, centery, radius, startAngle, endAngle);
}
/**
* adds an elliptical arc to the path which is centered at (x, y) position with the radii radiusX and radiusY
* starting at startAngle and ending at endAngle going in the given direction by counterclockwise.
* @param {number} x - the x-axis (horizontal) coordinate of the ellipse's center.
* @param {number} y - the y-axis (vertical) coordinate of the ellipse's center.
* @param {number} radiusX - the ellipse's major-axis radius. Must be non-negative.
* @param {number} radiusY - the ellipse's minor-axis radius. Must be non-negative.
* @param {number} rotation - the rotation of the ellipse, expressed in radians.
* @param {number} startAngle - the angle at which the ellipse starts, measured clockwise from the positive x-axis and expressed in radians.
* @param {number} endAngle - the angle at which the ellipse ends, measured clockwise from the positive x-axis and expressed in radians.
* @param {boolean} [anticlockwise=false] - an optional boolean value which, if true, draws the ellipse counterclockwise (anticlockwise).
*/
ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle, anticlockwise = false) {
// based on from https://github.com/karellodewijk/canvas-webgl/blob/master/canvas-webgl.js
if (startAngle === endAngle) return;
let fullCircle = anticlockwise ? Math.abs(startAngle - endAngle) >= (TAU) : Math.abs(endAngle - startAngle) >= (TAU);
//bring angles all in [0, 2*PI] range
startAngle = startAngle % (TAU);
endAngle = endAngle % (TAU);
if (startAngle < 0) startAngle += TAU;
if (endAngle < 0) endAngle += TAU;
if (startAngle >= endAngle) {
endAngle += TAU;
}
let diff = endAngle - startAngle;
let direction = 1;
if (anticlockwise) {
direction = -1;
diff = TAU - diff;
}
if (fullCircle) diff = TAU;
const length = (diff * radiusX + diff * radiusY) / 2;
const nr_of_interpolation_points = length / this.arcResolution;
const dangle = diff / nr_of_interpolation_points;
const angleStep = dangle * direction;
let angle = startAngle;
const cos_rotation = Math.cos(rotation);
const sin_rotation = Math.sin(rotation);
this.moveTo(x + radiusX * Math.cos(startAngle), y + radiusY * Math.sin(startAngle));
for (let j = 0; j < nr_of_interpolation_points; j++) {
const _x1 = radiusX * Math.cos(angle);
const _y1 = radiusY * Math.sin(angle);
const _x2 = x + _x1 * cos_rotation - _y1 * sin_rotation;
const _y2 = y + _x1 * sin_rotation + _y1 * cos_rotation;
this.lineTo(_x2, _y2);
angle += angleStep;
}
// close the ellipse
this.lineTo(x + radiusX * Math.cos(startAngle), y + radiusY * Math.sin(startAngle));
this.isDirty = true;
}
/**
* Adds a quadratic Bézier curve to the path.
* @param {number} cpX - The x-coordinate of the control point.
* @param {number} cpY - The y-coordinate of the control point.
* @param {number} x - The x-coordinate of the end point of the curve.
* @param {number} y - The y-coordinate of the end point of the curve.
*/
quadraticCurveTo(cpX, cpY, x, y) {
const points = this.points;
const startPoint = this.startPoint;
const lastPoint = points.length === 0 ? startPoint : points[points.length - 1];
const endPoint = pool.pull("Point").set(x, y);
const controlPoint = pool.pull("Point").set(cpX, cpY);
const resolution = this.arcResolution;
const t = 1 / resolution;
for (let i = 1; i <= resolution; i++) {
this.lineTo(
lastPoint.x * Math.pow(1 - t * i, 2) + controlPoint.x * 2 * (1 - t * i) * t * i + endPoint.x * Math.pow(t * i, 2),
lastPoint.y * Math.pow(1 - t * i, 2) + controlPoint.y * 2 * (1 - t * i) * t * i + endPoint.y * Math.pow(t * i, 2)
);
}
pool.push(endPoint, controlPoint);
this.isDirty = true;
}
/**
* Adds a cubic Bézier curve to the path.
* @param {number} cp1X - The x-coordinate of the first control point.
* @param {number} cp1Y - The y-coordinate of the first control point.
* @param {number} cp2X - The x-coordinate of the second control point.
* @param {number} cp2Y - The y-coordinate of the second control point.
* @param {number} x - The x-coordinate of the end point of the curve.
* @param {number} y - The y-coordinate of the end point of the curve.
*/
bezierCurveTo(cp1X, cp1Y, cp2X, cp2Y, x, y) {
const points = this.points;
const startPoint = this.startPoint;
const lastPoint = points.length === 0 ? startPoint : points[points.length - 1];
const endPoint = pool.pull("Point").set(x, y);
const controlPoint1 = pool.pull("Point").set(cp1X, cp1Y);
const controlPoint2 = pool.pull("Point").set(cp2X, cp2Y);
const resolution = this.arcResolution;
const t = 1 / resolution;
for (let i = 1; i <= resolution; i++) {
this.lineTo(
lastPoint.x * Math.pow(1 - t * i, 3) + controlPoint1.x * 3 * Math.pow(1 - t * i, 2) * t * i + controlPoint2.x * 3 * (1 - t * i) * Math.pow(t * i, 2) + endPoint.x * Math.pow(t * i, 3),
lastPoint.y * Math.pow(1 - t * i, 3) + controlPoint1.y * 3 * Math.pow(1 - t * i, 2) * t * i + controlPoint2.y * 3 * (1 - t * i) * Math.pow(t * i, 2) + endPoint.y * Math.pow(t * i, 3)
);
}
pool.push(endPoint, controlPoint1, controlPoint2);
this.isDirty = true;
}
/**
* creates a path for a rectangle at position (x, y) with a size that is determined by width and height.
* @param {number} x - the x-axis coordinate of the rectangle's starting point.
* @param {number} y - the y-axis coordinate of the rectangle's starting point.
* @param {number} width - the rectangle's width. Positive values are to the right, and negative to the left.
* @param {number} height - the rectangle's height. Positive values are down, and negative are up.
*/
rect(x, y, width, height) {
this.moveTo(x, y);
this.lineTo(x + width, y);
this.moveTo(x + width, y);
this.lineTo(x + width, y + height);
this.moveTo(x + width, y + height);
this.lineTo(x, y + height);
this.moveTo(x, y + height);
this.lineTo(x, y);
this.isDirty = true;
}
/**
* adds an rounded rectangle to the current path.
* @param {number} x - the x-axis coordinate of the rectangle's starting point.
* @param {number} y - the y-axis coordinate of the rectangle's starting point.
* @param {number} width - the rectangle's width. Positive values are to the right, and negative to the left.
* @param {number} height - the rectangle's height. Positive values are down, and negative are up.
* @param {number} radius - the arc's radius to draw the borders. Must be positive.
*/
roundRect(x, y, width, height, radius) {
this.moveTo(x + radius, y);
this.lineTo(x + width - radius, y);
this.arcTo(x + width, y, x + width, y + radius, radius);
this.moveTo(x + width, y + radius);
this.lineTo(x + width, y + height - radius);
this.arcTo(x + width, y + height, x + width - radius, y + height, radius);
this.moveTo(x + width - radius, y + height);
this.lineTo(x + radius, y + height);
this.arcTo(x, y + height, x, y + height - radius, radius);
this.moveTo(x, y + height - radius);
this.lineTo(x, y + radius);
this.arcTo(x, y, x + radius, y, radius);
this.isDirty = true;
}
}