@js-draw/math 1.0.0 → 1.2.0

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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cjs/Color4.d.ts +40 -0
  3. package/dist/cjs/Color4.js +102 -0
  4. package/dist/cjs/Color4.test.d.ts +1 -0
  5. package/dist/cjs/Mat33.test.d.ts +1 -0
  6. package/dist/cjs/Vec2.test.d.ts +1 -0
  7. package/dist/cjs/Vec3.test.d.ts +1 -0
  8. package/dist/cjs/polynomial/solveQuadratic.test.d.ts +1 -0
  9. package/dist/cjs/rounding.test.d.ts +1 -0
  10. package/dist/cjs/shapes/LineSegment2.test.d.ts +1 -0
  11. package/dist/cjs/shapes/Path.fromString.test.d.ts +1 -0
  12. package/dist/cjs/shapes/Path.test.d.ts +1 -0
  13. package/dist/cjs/shapes/Path.toString.test.d.ts +1 -0
  14. package/dist/cjs/shapes/QuadraticBezier.test.d.ts +1 -0
  15. package/dist/cjs/shapes/Rect2.test.d.ts +1 -0
  16. package/dist/cjs/shapes/Triangle.test.d.ts +1 -0
  17. package/dist/mjs/Color4.d.ts +40 -0
  18. package/dist/mjs/Color4.mjs +102 -0
  19. package/dist/mjs/Color4.test.d.ts +1 -0
  20. package/dist/mjs/Mat33.test.d.ts +1 -0
  21. package/dist/mjs/Vec2.test.d.ts +1 -0
  22. package/dist/mjs/Vec3.test.d.ts +1 -0
  23. package/dist/mjs/polynomial/solveQuadratic.test.d.ts +1 -0
  24. package/dist/mjs/rounding.test.d.ts +1 -0
  25. package/dist/mjs/shapes/LineSegment2.test.d.ts +1 -0
  26. package/dist/mjs/shapes/Path.fromString.test.d.ts +1 -0
  27. package/dist/mjs/shapes/Path.test.d.ts +1 -0
  28. package/dist/mjs/shapes/Path.toString.test.d.ts +1 -0
  29. package/dist/mjs/shapes/QuadraticBezier.test.d.ts +1 -0
  30. package/dist/mjs/shapes/Rect2.test.d.ts +1 -0
  31. package/dist/mjs/shapes/Triangle.test.d.ts +1 -0
  32. package/dist-test/test_imports/package-lock.json +13 -0
  33. package/dist-test/test_imports/package.json +12 -0
  34. package/dist-test/test_imports/test-imports.js +15 -0
  35. package/dist-test/test_imports/test-require.cjs +15 -0
  36. package/package.json +4 -3
  37. package/src/Color4.test.ts +94 -0
  38. package/src/Color4.ts +430 -0
  39. package/src/Mat33.test.ts +244 -0
  40. package/src/Mat33.ts +450 -0
  41. package/src/Vec2.test.ts +30 -0
  42. package/src/Vec2.ts +49 -0
  43. package/src/Vec3.test.ts +51 -0
  44. package/src/Vec3.ts +245 -0
  45. package/src/lib.ts +42 -0
  46. package/src/polynomial/solveQuadratic.test.ts +39 -0
  47. package/src/polynomial/solveQuadratic.ts +43 -0
  48. package/src/rounding.test.ts +65 -0
  49. package/src/rounding.ts +167 -0
  50. package/src/shapes/Abstract2DShape.ts +63 -0
  51. package/src/shapes/BezierJSWrapper.ts +93 -0
  52. package/src/shapes/CubicBezier.ts +35 -0
  53. package/src/shapes/LineSegment2.test.ts +99 -0
  54. package/src/shapes/LineSegment2.ts +232 -0
  55. package/src/shapes/Path.fromString.test.ts +223 -0
  56. package/src/shapes/Path.test.ts +309 -0
  57. package/src/shapes/Path.toString.test.ts +77 -0
  58. package/src/shapes/Path.ts +963 -0
  59. package/src/shapes/PointShape2D.ts +33 -0
  60. package/src/shapes/QuadraticBezier.test.ts +31 -0
  61. package/src/shapes/QuadraticBezier.ts +142 -0
  62. package/src/shapes/Rect2.test.ts +209 -0
  63. package/src/shapes/Rect2.ts +346 -0
  64. package/src/shapes/Triangle.test.ts +61 -0
  65. package/src/shapes/Triangle.ts +139 -0
package/src/Color4.ts ADDED
@@ -0,0 +1,430 @@
1
+ import Vec3 from './Vec3';
2
+
3
+ /**
4
+ * Represents a color.
5
+ *
6
+ * @example
7
+ * ```ts,runnable,console
8
+ * import { Color4 } from '@js-draw/math';
9
+ *
10
+ * console.log('Red:', Color4.fromString('#f00'));
11
+ * console.log('Also red:', Color4.ofRGB(1, 0, 0), Color4.red);
12
+ * console.log('Mixing red and blue:', Color4.red.mix(Color4.blue, 0.5));
13
+ * console.log('To string:', Color4.orange.toHexString());
14
+ * ```
15
+ */
16
+ export default class Color4 {
17
+ private constructor(
18
+ /** Red component. Should be in the range [0, 1]. */
19
+ public readonly r: number,
20
+
21
+ /** Green component. ${\tt g} \in [0, 1]$ */
22
+ public readonly g: number,
23
+
24
+ /** Blue component. ${\tt b} \in [0, 1]$ */
25
+ public readonly b: number,
26
+
27
+ /** Alpha/transparent component. ${\tt a} \in [0, 1]$. 0 = transparent */
28
+ public readonly a: number
29
+ ) {
30
+ }
31
+
32
+ /**
33
+ * Create a color from red, green, blue components. The color is fully opaque (`a = 1.0`).
34
+ *
35
+ * Each component should be in the range [0, 1].
36
+ */
37
+ public static ofRGB(red: number, green: number, blue: number): Color4 {
38
+ return Color4.ofRGBA(red, green, blue, 1.0);
39
+ }
40
+
41
+ public static ofRGBA(red: number, green: number, blue: number, alpha: number): Color4 {
42
+ red = Math.max(0, Math.min(red, 1));
43
+ green = Math.max(0, Math.min(green, 1));
44
+ blue = Math.max(0, Math.min(blue, 1));
45
+ alpha = Math.max(0, Math.min(alpha, 1));
46
+
47
+ return new Color4(red, green, blue, alpha);
48
+ }
49
+
50
+ public static fromHex(hexString: string): Color4 {
51
+ // Remove starting '#' (if present)
52
+ hexString = (hexString.match(/^[#]?(.*)$/) ?? [])[1];
53
+ hexString = hexString.toUpperCase();
54
+
55
+ if (!hexString.match(/^[0-9A-F]+$/)) {
56
+ throw new Error(`${hexString} is not in a valid format.`);
57
+ }
58
+
59
+ // RGBA or RGB
60
+ if (hexString.length === 3 || hexString.length === 4) {
61
+ // Each character is a component
62
+ const components = hexString.split('');
63
+
64
+ // Convert to RRGGBBAA or RRGGBB format
65
+ hexString = components.map(component => `${component}0`).join('');
66
+ }
67
+
68
+ if (hexString.length === 6) {
69
+ // Alpha component
70
+ hexString += 'FF';
71
+ }
72
+
73
+ const components: number[] = [];
74
+ for (let i = 2; i <= hexString.length; i += 2) {
75
+ const chunk = hexString.substring(i - 2, i);
76
+ components.push(parseInt(chunk, 16) / 255);
77
+ }
78
+
79
+ if (components.length !== 4) {
80
+ throw new Error(`Unable to parse ${hexString}: Wrong number of components.`);
81
+ }
82
+
83
+ return Color4.ofRGBA(components[0], components[1], components[2], components[3]);
84
+ }
85
+
86
+ /** Like fromHex, but can handle additional colors if an `HTMLCanvasElement` is available. */
87
+ public static fromString(text: string): Color4 {
88
+ if (text.startsWith('#')) {
89
+ return Color4.fromHex(text);
90
+ }
91
+
92
+ if (text === 'none' || text === 'transparent') {
93
+ return Color4.transparent;
94
+ }
95
+
96
+ // rgba?: Match both rgb and rgba strings.
97
+ // ([,0-9.]+): Match any string of only numeric, '.' and ',' characters.
98
+ const rgbRegex = /^rgba?\(([,0-9.]+)\)$/i;
99
+ const rgbMatch = text.replace(/\s*/g, '').match(rgbRegex);
100
+
101
+ if (rgbMatch) {
102
+ const componentsListStr = rgbMatch[1];
103
+ const componentsList = JSON.parse(`[ ${componentsListStr} ]`);
104
+
105
+ if (componentsList.length === 3) {
106
+ return Color4.ofRGB(
107
+ componentsList[0] / 255, componentsList[1] / 255, componentsList[2] / 255
108
+ );
109
+ } else if (componentsList.length === 4) {
110
+ return Color4.ofRGBA(
111
+ componentsList[0] / 255, componentsList[1] / 255, componentsList[2] / 255, componentsList[3]
112
+ );
113
+ } else {
114
+ throw new Error(`RGB string, ${text}, has wrong number of components: ${componentsList.length}`);
115
+ }
116
+ }
117
+
118
+ // Otherwise, try to use an HTMLCanvasElement to determine the color.
119
+ // Note: We may be unable to create an HTMLCanvasElement if running as a unit test.
120
+ const canvas = document.createElement('canvas');
121
+ canvas.width = 1;
122
+ canvas.height = 1;
123
+
124
+ const ctx = canvas.getContext('2d')!;
125
+ ctx.fillStyle = text;
126
+ ctx.fillRect(0, 0, 1, 1);
127
+
128
+ const data = ctx.getImageData(0, 0, 1, 1);
129
+ const red = data.data[0] / 255;
130
+ const green = data.data[1] / 255;
131
+ const blue = data.data[2] / 255;
132
+ const alpha = data.data[3] / 255;
133
+
134
+ return Color4.ofRGBA(red, green, blue, alpha);
135
+ }
136
+
137
+ /** @returns true if `this` and `other` are approximately equal. */
138
+ public eq(other: Color4|null|undefined): boolean {
139
+ if (other == null) {
140
+ return false;
141
+ }
142
+
143
+ // If both completely transparent,
144
+ if (this.a === 0 && other.a === 0) {
145
+ return true;
146
+ }
147
+
148
+ return this.toHexString() === other.toHexString();
149
+ }
150
+
151
+ /**
152
+ * If `fractionTo` is not in the range $[0, 1]$, it will be clamped to the nearest number
153
+ * in that range. For example, `a.mix(b, -1)` is equivalent to `a.mix(b, 0)`.
154
+ *
155
+ * @returns a color `fractionTo` of the way from this color to `other`.
156
+ *
157
+ * @example
158
+ * ```ts
159
+ * Color4.ofRGB(1, 0, 0).mix(Color4.ofRGB(0, 1, 0), 0.1) // -> Color4(0.9, 0.1, 0)
160
+ * ```
161
+ */
162
+ public mix(other: Color4, fractionTo: number): Color4 {
163
+ fractionTo = Math.min(Math.max(fractionTo, 0), 1);
164
+ const fractionOfThis = 1 - fractionTo;
165
+ return new Color4(
166
+ this.r * fractionOfThis + other.r * fractionTo,
167
+ this.g * fractionOfThis + other.g * fractionTo,
168
+ this.b * fractionOfThis + other.b * fractionTo,
169
+ this.a * fractionOfThis + other.a * fractionTo,
170
+ );
171
+ }
172
+
173
+ /**
174
+ * Ignoring this color's alpha component, returns a vector with components,
175
+ * $$
176
+ * \begin{pmatrix} \colorbox{#F44}{\tt r} \\ \colorbox{#4F4}{\tt g} \\ \colorbox{#44F}{\tt b} \end{pmatrix}
177
+ * $$
178
+ */
179
+ public get rgb() {
180
+ return Vec3.of(this.r, this.g, this.b);
181
+ }
182
+
183
+ /**
184
+ * Returns the [relative luminance](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef)
185
+ * of this color in the sRGB color space.
186
+ *
187
+ * Ignores the alpha component.
188
+ */
189
+ public relativeLuminance(): number {
190
+ // References:
191
+ // - https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
192
+ // - https://stackoverflow.com/a/9733420
193
+
194
+ // Normalize the components, as per above
195
+ const components = [ this.r, this.g, this.b ].map(component => {
196
+ if (component < 0.03928) {
197
+ return component / 12.92;
198
+ } else {
199
+ return Math.pow((component + 0.055) / 1.055, 2.4);
200
+ }
201
+ });
202
+
203
+ // From w3.org,
204
+ // > For the sRGB colorspace, the relative luminance of a color is
205
+ // > defined as L = 0.2126 * R + 0.7152 * G + 0.0722 * B
206
+ // where R, G, B are defined in components above.
207
+ return 0.2126 * components[0] + 0.7152 * components[1] + 0.0722 * components[2];
208
+ }
209
+
210
+ /**
211
+ * Returns the [contrast ratio](https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef)
212
+ * between `colorA` and `colorB`.
213
+ */
214
+ public static contrastRatio(colorA: Color4, colorB: Color4): number {
215
+ const L1 = colorA.relativeLuminance();
216
+ const L2 = colorB.relativeLuminance();
217
+
218
+ return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
219
+ }
220
+
221
+ /**
222
+ * @returns the component-wise average of `colors`, or `Color4.transparent` if `colors` is empty.
223
+ */
224
+ public static average(colors: Color4[]) {
225
+ let averageA = 0;
226
+ let averageR = 0;
227
+ let averageG = 0;
228
+ let averageB = 0;
229
+
230
+ for (const color of colors) {
231
+ averageA += color.a;
232
+ averageR += color.r;
233
+ averageG += color.g;
234
+ averageB += color.b;
235
+ }
236
+
237
+ if (colors.length > 0) {
238
+ averageA /= colors.length;
239
+ averageR /= colors.length;
240
+ averageG /= colors.length;
241
+ averageB /= colors.length;
242
+ }
243
+
244
+ return new Color4(averageR, averageG, averageB, averageA);
245
+ }
246
+
247
+ /**
248
+ * Converts to (hue, saturation, value).
249
+ * See also https://en.wikipedia.org/wiki/HSL_and_HSV#General_approach
250
+ *
251
+ * The resultant hue is represented in radians and is thus in $[0, 2\pi]$.
252
+ */
253
+ public asHSV(): Vec3 {
254
+ // Ref: https://en.wikipedia.org/wiki/HSL_and_HSV#General_approach
255
+ //
256
+ // HUE:
257
+ // First, consider the unit cube. Rotate it such that one vertex is at the origin
258
+ // of a plane and its three neighboring vertices are equidistant from that plane:
259
+ //
260
+ // /\
261
+ // / | \
262
+ // 2 / 3 \ 1
263
+ // \ | /
264
+ // \ | /
265
+ // . \/ .
266
+ //
267
+ // .
268
+ //
269
+ // Let z be up and (x, y, 0) be in the plane.
270
+ //
271
+ // Label vectors 1,2,3 with R, G, and B, respectively. Let R's projection into the plane
272
+ // lie along the x axis.
273
+ //
274
+ // Because R is a unit vector and R, G, B are equidistant from the plane, they must
275
+ // form 30-60-90 triangles, which have side lengths proportional to (1, √3, 2)
276
+ //
277
+ // /|
278
+ // 1/ | (√3)/2
279
+ // / |
280
+ // 1/2
281
+ //
282
+ const minComponent = Math.min(this.r, this.g, this.b);
283
+ const maxComponent = Math.max(this.r, this.g, this.b);
284
+ const chroma = maxComponent - minComponent;
285
+
286
+ let hue;
287
+
288
+ // See https://en.wikipedia.org/wiki/HSL_and_HSV#General_approach
289
+ if (chroma === 0) {
290
+ hue = 0;
291
+ } else if (this.r >= this.g && this.r >= this.b) {
292
+ hue = ((this.g - this.b) / chroma) % 6;
293
+ } else if (this.g >= this.r && this.g >= this.b) {
294
+ hue = (this.b - this.r) / chroma + 2;
295
+ } else {
296
+ hue = (this.r - this.g) / chroma + 4;
297
+ }
298
+
299
+ // Convert to degree representation, then to radians.
300
+ hue *= 60;
301
+ hue *= Math.PI / 180;
302
+
303
+ // Ensure positivity.
304
+ if (hue < 0) {
305
+ hue += Math.PI * 2;
306
+ }
307
+
308
+ const value = maxComponent;
309
+ const saturation = value > 0 ? chroma / value : 0;
310
+
311
+ return Vec3.of(hue, saturation, value);
312
+ }
313
+
314
+ /**
315
+ * Creates a new `Color4` from a representation [in $HSV$](https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB).
316
+ *
317
+ * [Algorithm](https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB).
318
+ *
319
+ * Note that hue must be given **in radians**. While non-standard, this is consistent with
320
+ * {@link asHSV}.
321
+ *
322
+ * `hue` and `value` should range from 0 to 1.
323
+ *
324
+ * @param hue $H \in [0, 2\pi]$
325
+ * @param saturation $S_V \in [0, 1]$
326
+ * @param value $V \in [0, 1]$
327
+ */
328
+ public static fromHSV(hue: number, saturation: number, value: number) {
329
+ if (hue < 0) {
330
+ hue += Math.PI * 2;
331
+ }
332
+ hue %= Math.PI * 2;
333
+
334
+ // Clamp value and saturation to [0, 1]
335
+ value = Math.max(0, Math.min(1, value));
336
+ saturation = Math.max(0, Math.min(1, saturation));
337
+
338
+ // Formula from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB
339
+
340
+ // Saturation can be thought of as scaled chroma. Unapply the scaling.
341
+ // See https://en.wikipedia.org/wiki/HSL_and_HSV#Saturation
342
+ const chroma = value * saturation;
343
+
344
+ // Determines which edge of the projected color cube
345
+ const huePrime = hue / (Math.PI / 3);
346
+
347
+ const secondLargestComponent = chroma * (1 - Math.abs((huePrime % 2) - 1));
348
+
349
+ let rgb;
350
+ if (huePrime < 1) {
351
+ rgb = [ chroma, secondLargestComponent, 0 ];
352
+ } else if (huePrime < 2) {
353
+ rgb = [ secondLargestComponent, chroma, 0 ];
354
+ } else if (huePrime < 3) {
355
+ rgb = [ 0, chroma, secondLargestComponent ];
356
+ } else if (huePrime < 4) {
357
+ rgb = [ 0, secondLargestComponent, chroma ];
358
+ } else if (huePrime < 5) {
359
+ rgb = [ secondLargestComponent, 0, chroma ];
360
+ } else {
361
+ rgb = [ chroma, 0, secondLargestComponent ];
362
+ }
363
+
364
+ const adjustment = value - chroma;
365
+ return Color4.ofRGB(rgb[0] + adjustment, rgb[1] + adjustment, rgb[2] + adjustment);
366
+ }
367
+
368
+
369
+ /**
370
+ * Equivalent to `ofRGB(rgb.x, rgb.y, rgb.z)`.
371
+ *
372
+ * All components should be in the range `[0, 1]` (0 to 1 inclusive).
373
+ */
374
+ public static fromRGBVector(rgb: Vec3, alpha?: number) {
375
+ return Color4.ofRGBA(rgb.x, rgb.y, rgb.z, alpha ?? 1);
376
+ }
377
+
378
+ private hexString: string|null = null;
379
+
380
+ /**
381
+ * @returns a hexadecimal color string representation of `this`, in the form `#rrggbbaa`.
382
+ *
383
+ * @example
384
+ * ```
385
+ * Color4.red.toHexString(); // -> #ff0000ff
386
+ * ```
387
+ */
388
+ public toHexString(): string {
389
+ if (this.hexString) {
390
+ return this.hexString;
391
+ }
392
+
393
+ const componentToHex = (component: number): string => {
394
+ const res = Math.round(255 * component).toString(16);
395
+
396
+ if (res.length === 1) {
397
+ return `0${res}`;
398
+ }
399
+ return res;
400
+ };
401
+
402
+ const alpha = componentToHex(this.a);
403
+ const red = componentToHex(this.r);
404
+ const green = componentToHex(this.g);
405
+ const blue = componentToHex(this.b);
406
+ if (alpha === 'ff') {
407
+ return `#${red}${green}${blue}`;
408
+ }
409
+ this.hexString = `#${red}${green}${blue}${alpha}`;
410
+ return this.hexString;
411
+ }
412
+
413
+ public toString() {
414
+ return this.toHexString();
415
+ }
416
+
417
+ public static transparent = Color4.ofRGBA(0, 0, 0, 0);
418
+ public static red = Color4.ofRGB(1.0, 0.0, 0.0);
419
+ public static orange = Color4.ofRGB(1.0, 0.65, 0.0);
420
+ public static green = Color4.ofRGB(0.0, 1.0, 0.0);
421
+ public static blue = Color4.ofRGB(0.0, 0.0, 1.0);
422
+ public static purple = Color4.ofRGB(0.5, 0.2, 0.5);
423
+ public static yellow = Color4.ofRGB(1, 1, 0.1);
424
+ public static clay = Color4.ofRGB(0.8, 0.4, 0.2);
425
+ public static black = Color4.ofRGB(0, 0, 0);
426
+ public static gray = Color4.ofRGB(0.5, 0.5, 0.5);
427
+ public static white = Color4.ofRGB(1, 1, 1);
428
+ }
429
+
430
+ export { Color4 };
@@ -0,0 +1,244 @@
1
+ import Mat33 from './Mat33';
2
+ import { Point2, Vec2 } from './Vec2';
3
+ import Vec3 from './Vec3';
4
+
5
+
6
+ describe('Mat33 tests', () => {
7
+ it('equality', () => {
8
+ expect(Mat33.identity).objEq(Mat33.identity);
9
+ expect(new Mat33(
10
+ 0.1, 0.2, 0.3,
11
+ 0.4, 0.5, 0.6,
12
+ 0.7, 0.8, -0.9
13
+ )).objEq(new Mat33(
14
+ 0.2, 0.1, 0.4,
15
+ 0.5, 0.5, 0.7,
16
+ 0.7, 0.8, -0.9
17
+ ), 0.2);
18
+ });
19
+
20
+ it('transposition', () => {
21
+ expect(Mat33.identity.transposed()).objEq(Mat33.identity);
22
+ expect(new Mat33(
23
+ 1, 2, 0,
24
+ 0, 0, 0,
25
+ 0, 1, 0
26
+ ).transposed()).objEq(new Mat33(
27
+ 1, 0, 0,
28
+ 2, 0, 1,
29
+ 0, 0, 0
30
+ ));
31
+ });
32
+
33
+ it('multiplication', () => {
34
+ const M = new Mat33(
35
+ 1, 2, 3,
36
+ 4, 5, 6,
37
+ 7, 8, 9
38
+ );
39
+
40
+ expect(Mat33.identity.rightMul(Mat33.identity)).objEq(Mat33.identity);
41
+ expect(M.rightMul(Mat33.identity)).objEq(M);
42
+ expect(M.rightMul(new Mat33(
43
+ 1, 0, 0,
44
+ 0, 2, 0,
45
+ 0, 0, 1
46
+ ))).objEq(new Mat33(
47
+ 1, 4, 3,
48
+ 4, 10, 6,
49
+ 7, 16, 9
50
+ ));
51
+ expect(M.rightMul(new Mat33(
52
+ 2, 0, 1,
53
+ 0, 1, 0,
54
+ 0, 0, 3
55
+ ))).objEq(new Mat33(
56
+ 2, 2, 10,
57
+ 8, 5, 22,
58
+ 14, 8, 34
59
+ ));
60
+ });
61
+
62
+ it('the inverse of the identity matrix should be the identity matrix', () => {
63
+ const fuzz = 0.01;
64
+ expect(Mat33.identity.inverse()).objEq(Mat33.identity, fuzz);
65
+
66
+ const M = new Mat33(
67
+ 1, 2, 3,
68
+ 4, 1, 0,
69
+ 2, 3, 0
70
+ );
71
+ expect(M.inverse().rightMul(M)).objEq(Mat33.identity, fuzz);
72
+ });
73
+
74
+ it('90 degree z-rotation matricies should rotate 90 degrees counter clockwise', () => {
75
+ const fuzz = 0.001;
76
+
77
+ const M = Mat33.zRotation(Math.PI / 2);
78
+ const rotated = M.transformVec2(Vec2.unitX);
79
+ expect(rotated).objEq(Vec2.unitY, fuzz);
80
+ expect(M.transformVec2(rotated)).objEq(Vec2.unitX.times(-1), fuzz);
81
+ });
82
+
83
+ it('z-rotation matricies should preserve the given origin', () => {
84
+ const testPairs: Array<[number, Vec2]> = [
85
+ [ Math.PI / 2, Vec2.zero ],
86
+ [ -Math.PI / 2, Vec2.zero ],
87
+ [ -Math.PI / 2, Vec2.of(10, 10) ],
88
+ ];
89
+
90
+ for (const [ angle, center ] of testPairs) {
91
+ expect(Mat33.zRotation(angle, center).transformVec2(center)).objEq(center);
92
+ }
93
+ });
94
+
95
+ it('translation matricies should translate Vec2s', () => {
96
+ const fuzz = 0.01;
97
+
98
+ const M = Mat33.translation(Vec2.of(1, -4));
99
+ expect(M.transformVec2(Vec2.of(0, 0))).objEq(Vec2.of(1, -4), fuzz);
100
+ expect(M.transformVec2(Vec2.of(-1, 3))).objEq(Vec2.of(0, -1), fuzz);
101
+ });
102
+
103
+ it('scaling matricies should scale about the provided center', () => {
104
+ const fuzz = 0.01;
105
+
106
+ const center = Vec2.of(1, -4);
107
+ const M = Mat33.scaling2D(2, center);
108
+ expect(M.transformVec2(center)).objEq(center, fuzz);
109
+ expect(M.transformVec2(Vec2.of(0, 0))).objEq(Vec2.of(-1, 4), fuzz);
110
+ });
111
+
112
+ it('calling inverse on singular matricies should result in the identity matrix', () => {
113
+ const fuzz = 0.001;
114
+ const singularMat = Mat33.ofRows(
115
+ Vec3.of(0, 0, 1),
116
+ Vec3.of(0, 1, 0),
117
+ Vec3.of(0, 1, 1)
118
+ );
119
+ expect(singularMat.invertable()).toBe(false);
120
+ expect(singularMat.inverse()).objEq(Mat33.identity, fuzz);
121
+ });
122
+
123
+ it('z-rotation matricies should be invertable', () => {
124
+ const fuzz = 0.01;
125
+ const M = Mat33.zRotation(-0.2617993877991494, Vec2.of(481, 329.5));
126
+ expect(
127
+ M.inverse().transformVec2(M.transformVec2(Vec2.unitX))
128
+ ).objEq(Vec2.unitX, fuzz);
129
+ expect(M.invertable());
130
+
131
+ const starterTransform = new Mat33(
132
+ -0.2588190451025205, -0.9659258262890688, 923.7645204565603,
133
+ 0.9659258262890688, -0.2588190451025205, -49.829447083761465,
134
+ 0, 0, 1
135
+ );
136
+ expect(starterTransform.invertable()).toBe(true);
137
+
138
+ const fullTransform = starterTransform.rightMul(M);
139
+ const fullTransformInverse = fullTransform.inverse();
140
+ expect(fullTransform.invertable()).toBe(true);
141
+
142
+ expect(
143
+ fullTransformInverse.rightMul(fullTransform)
144
+ ).objEq(Mat33.identity, fuzz);
145
+
146
+ expect(
147
+ fullTransform.transformVec2(fullTransformInverse.transformVec2(Vec2.unitX))
148
+ ).objEq(Vec2.unitX, fuzz);
149
+
150
+ expect(
151
+ fullTransformInverse.transformVec2(fullTransform.transformVec2(Vec2.unitX))
152
+ ).objEq(Vec2.unitX, fuzz);
153
+ });
154
+
155
+ it('z-rotation matrix inverses should undo the z-rotation', () => {
156
+ const testCases: Array<[ number, Point2 ]> = [
157
+ [ Math.PI / 2, Vec2.zero ],
158
+ [ Math.PI, Vec2.of(1, 1) ],
159
+ [ -Math.PI, Vec2.of(1, 1) ],
160
+ [ -Math.PI * 2, Vec2.of(1, 1) ],
161
+ [ -Math.PI * 2, Vec2.of(123, 456) ],
162
+ [ -Math.PI / 4, Vec2.of(123, 456) ],
163
+ [ 0.1, Vec2.of(1, 2) ],
164
+ ];
165
+
166
+ const fuzz = 0.00001;
167
+ for (const [ angle, center ] of testCases) {
168
+ const mat = Mat33.zRotation(angle, center);
169
+ expect(mat.inverse().rightMul(mat)).objEq(Mat33.identity, fuzz);
170
+ expect(mat.rightMul(mat.inverse())).objEq(Mat33.identity, fuzz);
171
+ }
172
+ });
173
+
174
+ it('z-rotation should preserve given origin', () => {
175
+ const testCases: Array<[ number, Point2 ]> = [
176
+ [ 6.205048847547065, Vec2.of(75.16363373235318, 104.29870408043762) ],
177
+ [ 1.234, Vec2.of(-56, 789) ],
178
+ [ -Math.PI, Vec2.of(-56, 789) ],
179
+ [ -Math.PI / 2, Vec2.of(-0.001, 1.0002) ],
180
+ ];
181
+
182
+ for (const [angle, rotationOrigin] of testCases) {
183
+ expect(Mat33.zRotation(angle, rotationOrigin).transformVec2(rotationOrigin)).objEq(rotationOrigin);
184
+ }
185
+ });
186
+
187
+ it('should correctly apply a mapping to all components', () => {
188
+ expect(
189
+ new Mat33(
190
+ 1, 2, 3,
191
+ 4, 5, 6,
192
+ 7, 8, 9,
193
+ ).mapEntries(component => component - 1)
194
+ ).toMatchObject(new Mat33(
195
+ 0, 1, 2,
196
+ 3, 4, 5,
197
+ 6, 7, 8,
198
+ ));
199
+ });
200
+
201
+ it('should convert CSS matrix(...) strings to matricies', () => {
202
+ // From MDN:
203
+ // ⎡ a c e ⎤
204
+ // ⎢ b d f ⎥ = matrix(a,b,c,d,e,f)
205
+ // ⎣ 0 0 1 ⎦
206
+ const identity = Mat33.fromCSSMatrix('matrix(1, 0, 0, 1, 0, 0)');
207
+ expect(identity).objEq(Mat33.identity);
208
+ expect(Mat33.fromCSSMatrix('matrix(1, 2, 3, 4, 5, 6)')).objEq(new Mat33(
209
+ 1, 3, 5,
210
+ 2, 4, 6,
211
+ 0, 0, 1,
212
+ ));
213
+ expect(Mat33.fromCSSMatrix('matrix(1e2, 2, 3, 4, 5, 6)')).objEq(new Mat33(
214
+ 1e2, 3, 5,
215
+ 2, 4, 6,
216
+ 0, 0, 1,
217
+ ));
218
+ expect(Mat33.fromCSSMatrix('matrix(1.6, 2, .3, 4, 5, 6)')).objEq(new Mat33(
219
+ 1.6, .3, 5,
220
+ 2, 4, 6,
221
+ 0, 0, 1,
222
+ ));
223
+ expect(Mat33.fromCSSMatrix('matrix(-1, 2, 3.E-2, 4, -5.123, -6.5)')).objEq(new Mat33(
224
+ -1, 0.03, -5.123,
225
+ 2, 4, -6.5,
226
+ 0, 0, 1,
227
+ ));
228
+ expect(Mat33.fromCSSMatrix('matrix(1.6,\n\t2, .3, 4, 5, 6)')).objEq(new Mat33(
229
+ 1.6, .3, 5,
230
+ 2, 4, 6,
231
+ 0, 0, 1,
232
+ ));
233
+ expect(Mat33.fromCSSMatrix('matrix(1.6,2, .3E-2, 4, 5, 6)')).objEq(new Mat33(
234
+ 1.6, 3e-3, 5,
235
+ 2, 4, 6,
236
+ 0, 0, 1,
237
+ ));
238
+ expect(Mat33.fromCSSMatrix('matrix(-1, 2e6, 3E-2,-5.123, -6.5e-1, 0.01)')).objEq(new Mat33(
239
+ -1, 3E-2, -6.5e-1,
240
+ 2e6, -5.123, 0.01,
241
+ 0, 0, 1,
242
+ ));
243
+ });
244
+ });