@js-draw/math 1.2.2 → 1.3.1

Sign up to get free protection for your applications and to get access to all the features.
package/src/Mat33.ts CHANGED
@@ -335,9 +335,37 @@ export class Mat33 {
335
335
  return Math.hypot(this.a1, this.a2);
336
336
  }
337
337
 
338
+ /** Returns the `idx`-th column (`idx` is 0-indexed). */
339
+ public getColumn(idx: number) {
340
+ return Vec3.of(
341
+ this.rows[0].at(idx),
342
+ this.rows[1].at(idx),
343
+ this.rows[2].at(idx),
344
+ );
345
+ }
346
+
347
+ /** Returns the magnitude of the entry with the largest entry */
348
+ public maximumEntryMagnitude() {
349
+ let greatestSoFar = Math.abs(this.a1);
350
+ for (const entry of this.toArray()) {
351
+ greatestSoFar = Math.max(greatestSoFar, Math.abs(entry));
352
+ }
353
+
354
+ return greatestSoFar;
355
+ }
356
+
338
357
  /**
339
358
  * Constructs a 3x3 translation matrix (for translating `Vec2`s) using
340
359
  * **transformVec2**.
360
+ *
361
+ * Creates a matrix in the form
362
+ * $$
363
+ * \begin{pmatrix}
364
+ * 1 & 0 & {\tt amount.x}\\
365
+ * 0 & 1 & {\tt amount.y}\\
366
+ * 0 & 0 & 1
367
+ * \end{pmatrix}
368
+ * $$
341
369
  */
342
370
  public static translation(amount: Vec2): Mat33 {
343
371
  // When transforming Vec2s by a 3x3 matrix, we give the input
@@ -392,7 +420,11 @@ export class Mat33 {
392
420
  return result.rightMul(Mat33.translation(center.times(-1)));
393
421
  }
394
422
 
395
- /** @see {@link fromCSSMatrix} */
423
+ /**
424
+ * **Note**: Assumes `this.c1 = this.c2 = 0` and `this.c3 = 1`.
425
+ *
426
+ * @see {@link fromCSSMatrix} and {@link toSafeCSSTransformList}
427
+ */
396
428
  public toCSSMatrix(): string {
397
429
  return `matrix(${this.a1},${this.b1},${this.a2},${this.b2},${this.a3},${this.b3})`;
398
430
  }
@@ -412,39 +444,118 @@ export class Mat33 {
412
444
  return Mat33.identity;
413
445
  }
414
446
 
415
- const numberExp = '([-]?\\d*(?:\\.\\d*)?(?:[eE][-]?\\d+)?)';
416
- const numberSepExp = '[, \\t\\n]';
417
- const regExpSource = `^\\s*matrix\\s*\\(${
418
- [
419
- // According to MDN, matrix(a,b,c,d,e,f) has form:
420
- // ⎡ a c e
421
- // ⎢ b d f ⎥
422
- // ⎣ 0 0 1 ⎦
423
- numberExp, numberExp, numberExp, // a, c, e
424
- numberExp, numberExp, numberExp, // b, d, f
425
- ].join(`${numberSepExp}+`)
426
- }${numberSepExp}*\\)\\s*$`;
427
- const matrixExp = new RegExp(regExpSource, 'i');
428
- const match = matrixExp.exec(cssString);
429
-
430
- if (!match) {
431
- throw new Error(`Unsupported transformation: ${cssString}`);
447
+ const parseArguments = (argumentString: string) => {
448
+ return argumentString.split(/[, \t\n]+/g).map(argString => {
449
+ let isPercentage = false;
450
+ if (argString.endsWith('%')) {
451
+ isPercentage = true;
452
+ argString = argString.substring(0, argString.length - 1);
453
+ }
454
+
455
+ // Remove trailing px units.
456
+ argString = argString.replace(/px$/ig, '');
457
+
458
+ const numberExp = /^[-]?\d*(?:\.\d*)?(?:[eE][-+]?\d+)?$/i;
459
+
460
+ if (!numberExp.exec(argString)) {
461
+ throw new Error(
462
+ `All arguments to transform functions must be numeric (state: ${
463
+ JSON.stringify({
464
+ currentArgument: argString,
465
+ allArguments: argumentString,
466
+ })
467
+ })`
468
+ );
469
+ }
470
+
471
+ let argNumber = parseFloat(argString);
472
+
473
+ if (isPercentage) {
474
+ argNumber /= 100;
475
+ }
476
+
477
+ return argNumber;
478
+ });
479
+ };
480
+
481
+
482
+ const keywordToAction = {
483
+ matrix: (matrixData: number[]) => {
484
+ if (matrixData.length !== 6) {
485
+ throw new Error(`Invalid matrix argument: ${matrixData}. Must have length 6`);
486
+ }
487
+
488
+ const a = matrixData[0];
489
+ const b = matrixData[1];
490
+ const c = matrixData[2];
491
+ const d = matrixData[3];
492
+ const e = matrixData[4];
493
+ const f = matrixData[5];
494
+
495
+ const transform = new Mat33(
496
+ a, c, e,
497
+ b, d, f,
498
+ 0, 0, 1
499
+ );
500
+ return transform;
501
+ },
502
+
503
+ scale: (scaleArgs: number[]) => {
504
+ let scaleX, scaleY;
505
+ if (scaleArgs.length === 1) {
506
+ scaleX = scaleArgs[0];
507
+ scaleY = scaleArgs[0];
508
+ } else if (scaleArgs.length === 2) {
509
+ scaleX = scaleArgs[0];
510
+ scaleY = scaleArgs[1];
511
+ } else {
512
+ throw new Error(`The scale() function only supports two arguments. Given: ${scaleArgs}`);
513
+ }
514
+
515
+ return Mat33.scaling2D(Vec2.of(scaleX, scaleY));
516
+ },
517
+
518
+ translate: (translateArgs: number[]) => {
519
+ let translateX = 0;
520
+ let translateY = 0;
521
+
522
+ if (translateArgs.length === 1) {
523
+ // If no y translation is given, assume 0.
524
+ translateX = translateArgs[0];
525
+ } else if (translateArgs.length === 2) {
526
+ translateX = translateArgs[0];
527
+ translateY = translateArgs[1];
528
+ } else {
529
+ throw new Error(`The translate() function requires either 1 or 2 arguments. Given ${translateArgs}`);
530
+ }
531
+
532
+ return Mat33.translation(Vec2.of(translateX, translateY));
533
+ },
534
+ };
535
+
536
+ // A command (\w+)
537
+ // followed by a set of arguments ([ \t\n0-9eE.,\-%]+)
538
+ const partRegex = /\s*(\w+)\s*\(([^)]*)\)/ig;
539
+ let match;
540
+ let matrix: Mat33|null = null;
541
+
542
+ while ((match = partRegex.exec(cssString)) !== null) {
543
+ const action = match[1].toLowerCase();
544
+ if (!(action in keywordToAction)) {
545
+ throw new Error(`Unsupported CSS transform action: ${action}`);
546
+ }
547
+
548
+ const args = parseArguments(match[2]);
549
+ const currentMatrix = keywordToAction[action as keyof typeof keywordToAction](args);
550
+
551
+ if (!matrix) {
552
+ matrix = currentMatrix;
553
+ } else {
554
+ matrix = matrix.rightMul(currentMatrix);
555
+ }
432
556
  }
433
557
 
434
- const matrixData = match.slice(1).map(entry => parseFloat(entry));
435
- const a = matrixData[0];
436
- const b = matrixData[1];
437
- const c = matrixData[2];
438
- const d = matrixData[3];
439
- const e = matrixData[4];
440
- const f = matrixData[5];
441
-
442
- const transform = new Mat33(
443
- a, c, e,
444
- b, d, f,
445
- 0, 0, 1
446
- );
447
- return transform;
558
+ return matrix ?? Mat33.identity;
448
559
  }
449
560
  }
450
561
  export default Mat33;
package/src/Vec3.ts CHANGED
@@ -63,6 +63,16 @@ export class Vec3 {
63
63
  return this.dot(this);
64
64
  }
65
65
 
66
+ /**
67
+ * Returns the entry of this with the greatest magnitude.
68
+ *
69
+ * In other words, returns $\max \{ |x| : x \in {\bf v} \}$, where ${\bf v}$ is the set of
70
+ * all entries of this vector.
71
+ */
72
+ public maximumEntryMagnitude(): number {
73
+ return Math.max(Math.abs(this.x), Math.max(Math.abs(this.y), Math.abs(this.z)));
74
+ }
75
+
66
76
  /**
67
77
  * Return this' angle in the XY plane (treats this as a Vec2).
68
78
  *
package/src/rounding.ts CHANGED
@@ -165,3 +165,4 @@ export const toStringOfSamePrecision = (num: number, ...references: string[]): s
165
165
  const negativeSign = textMatch[1];
166
166
  return cleanUpNumber(`${negativeSign}${preDecimal}.${postDecimal}`);
167
167
  };
168
+
@@ -97,6 +97,8 @@ export class Path {
97
97
  const geometry: GeometryArrayType = [];
98
98
 
99
99
  for (const part of this.parts) {
100
+ let exhaustivenessCheck: never;
101
+
100
102
  switch (part.kind) {
101
103
  case PathCommandType.CubicBezierTo:
102
104
  geometry.push(
@@ -124,6 +126,9 @@ export class Path {
124
126
  geometry.push(new PointShape2D(part.point));
125
127
  startPoint = part.point;
126
128
  break;
129
+ default:
130
+ exhaustivenessCheck = part;
131
+ return exhaustivenessCheck;
127
132
  }
128
133
  }
129
134
 
@@ -131,6 +136,38 @@ export class Path {
131
136
  return this.cachedGeometry;
132
137
  }
133
138
 
139
+ /**
140
+ * Iterates through the start/end points of each component in this path.
141
+ *
142
+ * If a start point is equivalent to the end point of the previous segment,
143
+ * the point is **not** emitted twice.
144
+ */
145
+ public *startEndPoints() {
146
+ yield this.startPoint;
147
+
148
+ for (const part of this.parts) {
149
+ let exhaustivenessCheck: never;
150
+
151
+ switch (part.kind) {
152
+ case PathCommandType.CubicBezierTo:
153
+ yield part.endPoint;
154
+ break;
155
+ case PathCommandType.QuadraticBezierTo:
156
+ yield part.endPoint;
157
+ break;
158
+ case PathCommandType.LineTo:
159
+ yield part.point;
160
+ break;
161
+ case PathCommandType.MoveTo:
162
+ yield part.point;
163
+ break;
164
+ default:
165
+ exhaustivenessCheck = part;
166
+ return exhaustivenessCheck;
167
+ }
168
+ }
169
+ }
170
+
134
171
  private cachedPolylineApproximation: LineSegment2[]|null = null;
135
172
 
136
173
  // Approximates this path with a group of line segments.
@@ -111,6 +111,27 @@ describe('Rect2', () => {
111
111
  expect(new Rect2(-100, -1, 200, 2).intersects(new Rect2(-5, 50, 10, 30))).toBe(false);
112
112
  });
113
113
 
114
+ it('should correctly compute the intersection of one rectangle and several others', () => {
115
+ const mainRect = new Rect2(334,156,333,179);
116
+ const shouldIntersect = [
117
+ new Rect2(400.8, 134.8, 8.4, 161.4),
118
+ new Rect2(324.8,93,164.4,75.2),
119
+ new Rect2(435.8,146.8,213.2,192.6),
120
+ new Rect2(550.8,211.8,3.4,3.4),
121
+ new Rect2(478.8,93.8,212.4,95.4),
122
+ ];
123
+ const shouldNotIntersect = [
124
+ new Rect2(200, 200, 1, 1),
125
+ ];
126
+
127
+ for (const rect of shouldIntersect) {
128
+ expect(mainRect.intersects(rect)).toBe(true);
129
+ }
130
+ for (const rect of shouldNotIntersect) {
131
+ expect(mainRect.intersects(rect)).toBe(false);
132
+ }
133
+ });
134
+
114
135
  it('intersecting rectangles should have their intersections correctly computed', () => {
115
136
  expect(new Rect2(-1, -1, 2, 2).intersection(Rect2.empty)).objEq(Rect2.empty);
116
137
  expect(new Rect2(-1, -1, 2, 2).intersection(new Rect2(0, 0, 3, 3))).objEq(
@@ -130,6 +151,19 @@ describe('Rect2', () => {
130
151
  expect(transformedBBox.containsRect(rect)).toBe(true);
131
152
  });
132
153
 
154
+ it('.grownBy should expand a rectangle by the given margin', () => {
155
+ expect(Rect2.empty.grownBy(0)).toBe(Rect2.empty);
156
+
157
+ // Should add padding to all sides.
158
+ expect(new Rect2(1, 2, 3, 4).grownBy(1)).objEq(new Rect2(0, 1, 5, 6));
159
+
160
+ // Shrinking should not result in negative widths/heights and
161
+ // should adjust x/y appropriately
162
+ expect(new Rect2(1, 2, 1, 2).grownBy(-1)).objEq(new Rect2(1.5, 3, 0, 0));
163
+ expect(new Rect2(1, 2, 4, 4).grownBy(-1)).objEq(new Rect2(2, 3, 2, 2));
164
+ expect(new Rect2(1, 2, 2, 8).grownBy(-2)).objEq(new Rect2(2, 4, 0, 4));
165
+ });
166
+
133
167
  describe('should correctly expand to include a given point', () => {
134
168
  it('Growing an empty rectange to include (1, 0)', () => {
135
169
  const originalRect = Rect2.empty;
@@ -21,7 +21,6 @@ export class Rect2 extends Abstract2DShape {
21
21
  // topLeft assumes up is -y
22
22
  public readonly topLeft: Point2;
23
23
  public readonly size: Vec2;
24
- public readonly bottomRight: Point2;
25
24
  public readonly area: number;
26
25
 
27
26
  public constructor(
@@ -45,7 +44,6 @@ export class Rect2 extends Abstract2DShape {
45
44
  // Precompute/store vector forms.
46
45
  this.topLeft = Vec2.of(this.x, this.y);
47
46
  this.size = Vec2.of(this.w, this.h);
48
- this.bottomRight = this.topLeft.plus(this.size);
49
47
  this.area = this.w * this.h;
50
48
  }
51
49
 
@@ -65,8 +63,8 @@ export class Rect2 extends Abstract2DShape {
65
63
 
66
64
  public containsRect(other: Rect2): boolean {
67
65
  return this.x <= other.x && this.y <= other.y
68
- && this.bottomRight.x >= other.bottomRight.x
69
- && this.bottomRight.y >= other.bottomRight.y;
66
+ && this.x + this.w >= other.x + other.w
67
+ && this.y + this.h >= other.y + other.h;
70
68
  }
71
69
 
72
70
  public intersects(other: Rect2): boolean {
@@ -159,6 +157,17 @@ export class Rect2 extends Abstract2DShape {
159
157
  return this;
160
158
  }
161
159
 
160
+ // Prevent width/height from being negative
161
+ if (margin < 0) {
162
+ const xMargin = -Math.min(-margin, this.w / 2);
163
+ const yMargin = -Math.min(-margin, this.h / 2);
164
+
165
+ return new Rect2(
166
+ this.x - xMargin, this.y - yMargin,
167
+ this.w + xMargin * 2, this.h + yMargin * 2,
168
+ );
169
+ }
170
+
162
171
  return new Rect2(
163
172
  this.x - margin, this.y - margin, this.w + margin * 2, this.h + margin * 2
164
173
  );
@@ -194,6 +203,10 @@ export class Rect2 extends Abstract2DShape {
194
203
  return Math.max(this.w, this.h);
195
204
  }
196
205
 
206
+ public get bottomRight() {
207
+ return this.topLeft.plus(this.size);
208
+ }
209
+
197
210
  public get topRight() {
198
211
  return this.bottomRight.plus(Vec2.of(0, -this.h));
199
212
  }
@@ -315,17 +328,17 @@ export class Rect2 extends Abstract2DShape {
315
328
  }
316
329
 
317
330
  const firstRect = rects[0];
318
- let minX: number = firstRect.topLeft.x;
319
- let minY: number = firstRect.topLeft.y;
320
- let maxX: number = firstRect.bottomRight.x;
321
- let maxY: number = firstRect.bottomRight.y;
331
+ let minX: number = firstRect.x;
332
+ let minY: number = firstRect.y;
333
+ let maxX: number = firstRect.x + firstRect.w;
334
+ let maxY: number = firstRect.y + firstRect.h;
322
335
 
323
336
  for (let i = 1; i < rects.length; i++) {
324
337
  const rect = rects[i];
325
- minX = Math.min(minX, rect.topLeft.x);
326
- minY = Math.min(minY, rect.topLeft.y);
327
- maxX = Math.max(maxX, rect.bottomRight.x);
328
- maxY = Math.max(maxY, rect.bottomRight.y);
338
+ minX = Math.min(minX, rect.x);
339
+ minY = Math.min(minY, rect.y);
340
+ maxX = Math.max(maxX, rect.x + rect.w);
341
+ maxY = Math.max(maxY, rect.y + rect.h);
329
342
  }
330
343
 
331
344
  return new Rect2(