@js-draw/math 1.2.2 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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(