glmaths 0.0.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.
@@ -0,0 +1,586 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import glmaths, { Mat4, mat4, Vec3, Vec4, Quat } from '../dist/esm/glmaths'
4
+
5
+ function closeTo(actual: number, expected: number, numDigits = 5) {
6
+ const pass = Math.abs(actual - expected) < Math.pow(10, -numDigits) / 2
7
+ assert.ok(pass, `expected ${actual} to be close to ${expected}`)
8
+ }
9
+
10
+ describe('Mat4', () => {
11
+ describe('constructor', () => {
12
+ it('creates zero matrix by default', () => {
13
+ const m = new Mat4()
14
+ for (let i = 0; i < 16; i++) assert.strictEqual(m[i], 0)
15
+ })
16
+ it('creates with given values', () => {
17
+ const m = Mat4.identity
18
+ assert.strictEqual(m[0], 1)
19
+ assert.strictEqual(m[5], 1)
20
+ assert.strictEqual(m[10], 1)
21
+ assert.strictEqual(m[15], 1)
22
+ })
23
+ it('extends Float32Array with length 16', () => {
24
+ assert.strictEqual(new Mat4().length, 16)
25
+ })
26
+ })
27
+
28
+ describe('identity', () => {
29
+ it('is correct', () => {
30
+ const m = Mat4.identity
31
+ for (let i = 0; i < 16; i++) {
32
+ assert.strictEqual(m[i], i % 5 === 0 ? 1 : 0)
33
+ }
34
+ })
35
+ })
36
+
37
+ describe('clone', () => {
38
+ it('clones independently', () => {
39
+ const a = Mat4.identity
40
+ const b = a.clone()
41
+ b[0] = 99
42
+ assert.strictEqual(a[0], 1)
43
+ })
44
+ })
45
+
46
+ describe('transpose', () => {
47
+ it('transposes in-place', () => {
48
+ const m = new Mat4(
49
+ 1, 2, 3, 4,
50
+ 5, 6, 7, 8,
51
+ 9, 10, 11, 12,
52
+ 13, 14, 15, 16
53
+ )
54
+ const r = m.transpose()
55
+ assert.strictEqual(r[1], 5)
56
+ assert.strictEqual(r[4], 2)
57
+ assert.strictEqual(r[2], 9)
58
+ assert.strictEqual(r[8], 3)
59
+ })
60
+ it('transposes to out', () => {
61
+ const m = Mat4.identity
62
+ const out = new Mat4()
63
+ m.transpose(out)
64
+ assert.strictEqual(out.exactEquals(Mat4.identity), true)
65
+ })
66
+ })
67
+
68
+ describe('invert', () => {
69
+ it('inverts identity to identity', () => {
70
+ const out = new Mat4()
71
+ Mat4.identity.invert(out)
72
+ for (let i = 0; i < 16; i++) {
73
+ closeTo(out[i], i % 5 === 0 ? 1 : 0)
74
+ }
75
+ })
76
+ it('m * m^-1 = identity', () => {
77
+ const m = Mat4.fromRotationTranslationScale(
78
+ Quat.fromAxisAngle(new Vec3(0, 1, 0), Math.PI / 4),
79
+ new Vec3(1, 2, 3),
80
+ new Vec3(1, 1, 1)
81
+ )
82
+ const inv = new Mat4()
83
+ m.invert(inv)
84
+ const result = new Mat4()
85
+ m.multiply(inv!, result)
86
+ for (let i = 0; i < 16; i++) {
87
+ closeTo(result[i], i % 5 === 0 ? 1 : 0, 4)
88
+ }
89
+ })
90
+ it('returns null for singular matrix', () => {
91
+ const m = new Mat4()
92
+ assert.strictEqual(m.invert(new Mat4()), null)
93
+ })
94
+ })
95
+
96
+ describe('determinant', () => {
97
+ it('identity determinant is 1', () => {
98
+ closeTo(Mat4.identity.determinant(), 1)
99
+ })
100
+ it('scaling matrix determinant is product of scales', () => {
101
+ const m = Mat4.fromScaling(new Vec3(2, 3, 4))
102
+ closeTo(m.determinant(), 24)
103
+ })
104
+ })
105
+
106
+ describe('multiply', () => {
107
+ it('identity * A = A', () => {
108
+ const a = Mat4.fromTranslation(new Vec3(1, 2, 3))
109
+ const out = new Mat4()
110
+ Mat4.identity.multiply(a, out)
111
+ for (let i = 0; i < 16; i++) closeTo(out[i], a[i])
112
+ })
113
+ it('mul alias works', () => {
114
+ const m = Mat4.identity
115
+ const out = new Mat4()
116
+ m.mul(Mat4.identity, out)
117
+ closeTo(out[0], 1)
118
+ })
119
+ })
120
+
121
+ describe('translate', () => {
122
+ it('translates identity', () => {
123
+ const m = Mat4.identity
124
+ const r = m.translate(new Vec3(5, 10, 15))
125
+ assert.strictEqual(r[12], 5)
126
+ assert.strictEqual(r[13], 10)
127
+ assert.strictEqual(r[14], 15)
128
+ })
129
+ it('translates to out', () => {
130
+ const m = Mat4.identity
131
+ const out = new Mat4()
132
+ m.translate(new Vec3(5, 10, 15), out)
133
+ assert.strictEqual(out[12], 5)
134
+ assert.strictEqual(out[13], 10)
135
+ assert.strictEqual(out[14], 15)
136
+ assert.strictEqual(m[12], 0)
137
+ })
138
+ })
139
+
140
+ describe('scale', () => {
141
+ it('scales identity', () => {
142
+ const m = Mat4.identity
143
+ const r = m.scale(new Vec3(2, 3, 4))
144
+ assert.strictEqual(r[0], 2)
145
+ assert.strictEqual(r[5], 3)
146
+ assert.strictEqual(r[10], 4)
147
+ })
148
+ })
149
+
150
+ describe('rotate', () => {
151
+ it('rotates identity around Y axis', () => {
152
+ const m = Mat4.identity
153
+ const r = m.rotate(Math.PI / 2, new Vec3(0, 1, 0))
154
+ closeTo(r![0], 0)
155
+ closeTo(r![8], 1)
156
+ })
157
+ it('returns null for zero axis', () => {
158
+ const m = Mat4.identity
159
+ assert.strictEqual(m.rotate(Math.PI / 2, new Vec3(0, 0, 0)), null)
160
+ })
161
+ })
162
+
163
+ describe('rotateX / rotateY / rotateZ', () => {
164
+ it('rotateX by PI/2', () => {
165
+ const m = Mat4.identity
166
+ const r = m.rotateX(Math.PI / 2)
167
+ closeTo(r[5], 0)
168
+ closeTo(r[6], 1)
169
+ closeTo(r[9], -1)
170
+ closeTo(r[10], 0)
171
+ })
172
+ it('rotateY by PI/2', () => {
173
+ const m = Mat4.identity
174
+ const r = m.rotateY(Math.PI / 2)
175
+ closeTo(r[0], 0)
176
+ closeTo(r[2], -1)
177
+ closeTo(r[8], 1)
178
+ closeTo(r[10], 0)
179
+ })
180
+ it('rotateZ by PI/2', () => {
181
+ const m = Mat4.identity
182
+ const r = m.rotateZ(Math.PI / 2)
183
+ closeTo(r[0], 0)
184
+ closeTo(r[1], 1)
185
+ closeTo(r[4], -1)
186
+ closeTo(r[5], 0)
187
+ })
188
+ })
189
+
190
+ describe('getTranslation', () => {
191
+ it('extracts translation', () => {
192
+ const m = Mat4.fromTranslation(new Vec3(5, 10, 15))
193
+ const t = m.getTranslation()
194
+ assert.strictEqual(t[0], 5)
195
+ assert.strictEqual(t[1], 10)
196
+ assert.strictEqual(t[2], 15)
197
+ })
198
+ })
199
+
200
+ describe('getScaling', () => {
201
+ it('extracts scaling', () => {
202
+ const m = Mat4.fromScaling(new Vec3(2, 3, 4))
203
+ const s = m.getScaling()
204
+ closeTo(s[0], 2)
205
+ closeTo(s[1], 3)
206
+ closeTo(s[2], 4)
207
+ })
208
+ })
209
+
210
+ describe('getRotation', () => {
211
+ it('extracts rotation from identity', () => {
212
+ const q = Mat4.identity.getRotation()
213
+ closeTo(q[0], 0)
214
+ closeTo(q[1], 0)
215
+ closeTo(q[2], 0)
216
+ closeTo(q[3], 1)
217
+ })
218
+ })
219
+
220
+ describe('decompose', () => {
221
+ it('decomposes a TRS matrix', () => {
222
+ const q = Quat.fromAxisAngle(new Vec3(0, 1, 0), Math.PI / 4)
223
+ const t = new Vec3(1, 2, 3)
224
+ const s = new Vec3(2, 3, 4)
225
+ const m = Mat4.fromRotationTranslationScale(q, t, s)
226
+
227
+ const outQ = new Quat()
228
+ const outT = new Vec3()
229
+ const outS = new Vec3()
230
+ m.decompose(outQ, outT, outS)
231
+
232
+ closeTo(outT[0], 1)
233
+ closeTo(outT[1], 2)
234
+ closeTo(outT[2], 3)
235
+ closeTo(outS[0], 2)
236
+ closeTo(outS[1], 3)
237
+ closeTo(outS[2], 4)
238
+ })
239
+ })
240
+
241
+ describe('static fromTranslation', () => {
242
+ it('creates translation matrix', () => {
243
+ const m = Mat4.fromTranslation(new Vec3(1, 2, 3))
244
+ assert.strictEqual(m[12], 1)
245
+ assert.strictEqual(m[13], 2)
246
+ assert.strictEqual(m[14], 3)
247
+ assert.strictEqual(m[0], 1)
248
+ assert.strictEqual(m[15], 1)
249
+ })
250
+ })
251
+
252
+ describe('static fromScaling', () => {
253
+ it('creates scaling matrix', () => {
254
+ const m = Mat4.fromScaling(new Vec3(2, 3, 4))
255
+ assert.strictEqual(m[0], 2)
256
+ assert.strictEqual(m[5], 3)
257
+ assert.strictEqual(m[10], 4)
258
+ assert.strictEqual(m[15], 1)
259
+ })
260
+ })
261
+
262
+ describe('static fromRotation', () => {
263
+ it('creates rotation matrix', () => {
264
+ const m = Mat4.fromRotation(Math.PI / 2, new Vec3(0, 1, 0))
265
+ assert.notStrictEqual(m, null)
266
+ closeTo(m![0], 0)
267
+ assert.strictEqual(m![15], 1)
268
+ })
269
+ it('returns null for zero axis', () => {
270
+ assert.strictEqual(Mat4.fromRotation(Math.PI, new Vec3(0, 0, 0)), null)
271
+ })
272
+ })
273
+
274
+ describe('static axis rotation', () => {
275
+ it('fromXRotation', () => {
276
+ const m = Mat4.fromXRotation(Math.PI / 2)
277
+ closeTo(m[5], 0)
278
+ closeTo(m[6], 1)
279
+ })
280
+ it('fromYRotation', () => {
281
+ const m = Mat4.fromYRotation(Math.PI / 2)
282
+ closeTo(m[0], 0)
283
+ closeTo(m[8], 1)
284
+ })
285
+ it('fromZRotation', () => {
286
+ const m = Mat4.fromZRotation(Math.PI / 2)
287
+ closeTo(m[0], 0)
288
+ closeTo(m[1], 1)
289
+ })
290
+ })
291
+
292
+ describe('static fromRotationTranslation', () => {
293
+ it('creates combined matrix', () => {
294
+ const q = Quat.identity
295
+ const v = new Vec3(1, 2, 3)
296
+ const m = Mat4.fromRotationTranslation(q, v)
297
+ assert.strictEqual(m[12], 1)
298
+ assert.strictEqual(m[13], 2)
299
+ assert.strictEqual(m[14], 3)
300
+ closeTo(m[0], 1)
301
+ })
302
+ })
303
+
304
+ describe('static fromRotationTranslationScale', () => {
305
+ it('creates TRS matrix', () => {
306
+ const m = Mat4.fromRotationTranslationScale(
307
+ Quat.identity,
308
+ new Vec3(1, 2, 3),
309
+ new Vec3(2, 3, 4)
310
+ )
311
+ assert.strictEqual(m[12], 1)
312
+ closeTo(m[0], 2)
313
+ closeTo(m[5], 3)
314
+ closeTo(m[10], 4)
315
+ })
316
+ })
317
+
318
+ describe('static fromQuat', () => {
319
+ it('identity quat gives identity matrix', () => {
320
+ const m = Mat4.fromQuat(Quat.identity)
321
+ closeTo(m[0], 1)
322
+ closeTo(m[5], 1)
323
+ closeTo(m[10], 1)
324
+ assert.strictEqual(m[15], 1)
325
+ })
326
+ })
327
+
328
+ describe('static perspectiveNO', () => {
329
+ it('creates perspective matrix with correct structure', () => {
330
+ const fovy = Math.PI / 4, aspect = 16 / 9, near = 0.1, far = 100
331
+ const m = Mat4.perspectiveNO(fovy, aspect, near, far)
332
+ const f = 1.0 / Math.tan(fovy / 2)
333
+ const nf = 1 / (near - far)
334
+ closeTo(m[0], f / aspect)
335
+ closeTo(m[5], f)
336
+ closeTo(m[10], (far + near) * nf)
337
+ assert.strictEqual(m[11], -1)
338
+ closeTo(m[14], 2 * far * near * nf)
339
+ assert.strictEqual(m[15], 0)
340
+ assert.strictEqual(m[1], 0)
341
+ assert.strictEqual(m[2], 0)
342
+ assert.strictEqual(m[3], 0)
343
+ assert.strictEqual(m[4], 0)
344
+ assert.strictEqual(m[6], 0)
345
+ assert.strictEqual(m[7], 0)
346
+ assert.strictEqual(m[8], 0)
347
+ assert.strictEqual(m[9], 0)
348
+ assert.strictEqual(m[12], 0)
349
+ assert.strictEqual(m[13], 0)
350
+ })
351
+ it('handles infinite far plane', () => {
352
+ const fovy = Math.PI / 4, aspect = 16 / 9, near = 0.1
353
+ const m = Mat4.perspectiveNO(fovy, aspect, near, Infinity)
354
+ const f = 1.0 / Math.tan(fovy / 2)
355
+ closeTo(m[0], f / aspect)
356
+ closeTo(m[5], f)
357
+ assert.strictEqual(m[10], -1)
358
+ assert.strictEqual(m[11], -1)
359
+ closeTo(m[14], -2 * near)
360
+ assert.strictEqual(m[15], 0)
361
+ })
362
+ it('handles null far plane same as infinite', () => {
363
+ const fovy = Math.PI / 4, aspect = 16 / 9, near = 0.1
364
+ const m = Mat4.perspectiveNO(fovy, aspect, near, null)
365
+ assert.strictEqual(m[10], -1)
366
+ closeTo(m[14], -2 * near)
367
+ })
368
+ it('perspective is alias for perspectiveNO', () => {
369
+ assert.strictEqual(Mat4.perspective, Mat4.perspectiveNO)
370
+ })
371
+ })
372
+
373
+ describe('static perspectiveZO', () => {
374
+ it('creates perspective matrix with [0,1] depth', () => {
375
+ const fovy = Math.PI / 4, aspect = 16 / 9, near = 0.1, far = 100
376
+ const m = Mat4.perspectiveZO(fovy, aspect, near, far)
377
+ const f = 1.0 / Math.tan(fovy / 2)
378
+ const nf = 1 / (near - far)
379
+ closeTo(m[0], f / aspect)
380
+ closeTo(m[5], f)
381
+ closeTo(m[10], far * nf)
382
+ assert.strictEqual(m[11], -1)
383
+ closeTo(m[14], far * near * nf)
384
+ assert.strictEqual(m[15], 0)
385
+ })
386
+ it('handles infinite far plane', () => {
387
+ const m = Mat4.perspectiveZO(Math.PI / 4, 16 / 9, 0.1, Infinity)
388
+ assert.strictEqual(m[10], -1)
389
+ closeTo(m[14], -0.1)
390
+ })
391
+ })
392
+
393
+ describe('static orthoNO', () => {
394
+ it('creates orthographic matrix with correct values', () => {
395
+ const left = -2, right = 2, bottom = -1, top = 1, near = 0.1, far = 100
396
+ const m = Mat4.orthoNO(left, right, bottom, top, near, far)
397
+ const lr = 1 / (left - right)
398
+ const bt = 1 / (bottom - top)
399
+ const nf = 1 / (near - far)
400
+ closeTo(m[0], -2 * lr)
401
+ closeTo(m[5], -2 * bt)
402
+ closeTo(m[10], 2 * nf)
403
+ closeTo(m[12], (left + right) * lr)
404
+ closeTo(m[13], (top + bottom) * bt)
405
+ closeTo(m[14], (far + near) * nf)
406
+ assert.strictEqual(m[15], 1)
407
+ assert.strictEqual(m[3], 0)
408
+ assert.strictEqual(m[7], 0)
409
+ assert.strictEqual(m[11], 0)
410
+ })
411
+ it('ortho is alias for orthoNO', () => {
412
+ assert.strictEqual(Mat4.ortho, Mat4.orthoNO)
413
+ })
414
+ })
415
+
416
+ describe('static orthoZO', () => {
417
+ it('creates orthographic matrix with [0,1] depth', () => {
418
+ const left = -1, right = 1, bottom = -1, top = 1, near = 0.1, far = 100
419
+ const m = Mat4.orthoZO(left, right, bottom, top, near, far)
420
+ const nf = 1 / (near - far)
421
+ closeTo(m[0], 1)
422
+ closeTo(m[5], 1)
423
+ closeTo(m[10], nf)
424
+ closeTo(m[14], near * nf)
425
+ assert.strictEqual(m[15], 1)
426
+ assert.strictEqual(m[11], 0)
427
+ })
428
+ })
429
+
430
+ describe('static frustum', () => {
431
+ it('creates frustum matrix with correct values', () => {
432
+ const left = -1, right = 1, bottom = -1, top = 1, near = 1, far = 100
433
+ const m = Mat4.frustum(left, right, bottom, top, near, far)
434
+ const rl = 1 / (right - left)
435
+ const tb = 1 / (top - bottom)
436
+ const nf = 1 / (near - far)
437
+ closeTo(m[0], near * 2 * rl)
438
+ closeTo(m[5], near * 2 * tb)
439
+ closeTo(m[8], (right + left) * rl)
440
+ closeTo(m[9], (top + bottom) * tb)
441
+ closeTo(m[10], (far + near) * nf)
442
+ assert.strictEqual(m[11], -1)
443
+ closeTo(m[14], far * near * 2 * nf)
444
+ assert.strictEqual(m[15], 0)
445
+ })
446
+ })
447
+
448
+ describe('static lookAt', () => {
449
+ it('looking along -Z from origin', () => {
450
+ const m = Mat4.lookAt(new Vec3(0, 0, 0), new Vec3(0, 0, -1), new Vec3(0, 1, 0))
451
+ closeTo(m[0], 1)
452
+ closeTo(m[5], 1)
453
+ closeTo(m[10], 1)
454
+ })
455
+ it('returns identity when eye equals center', () => {
456
+ const m = Mat4.lookAt(new Vec3(0, 0, 0), new Vec3(0, 0, 0), new Vec3(0, 1, 0))
457
+ assert.strictEqual(m[0], 1)
458
+ assert.strictEqual(m[5], 1)
459
+ assert.strictEqual(m[10], 1)
460
+ assert.strictEqual(m[15], 1)
461
+ })
462
+ })
463
+
464
+ describe('static targetTo', () => {
465
+ it('creates targeting matrix', () => {
466
+ const m = Mat4.targetTo(new Vec3(0, 0, 5), new Vec3(0, 0, 0), new Vec3(0, 1, 0))
467
+ assert.strictEqual(m[12], 0)
468
+ assert.strictEqual(m[13], 0)
469
+ assert.strictEqual(m[14], 5)
470
+ })
471
+ })
472
+
473
+ describe('plus / minus / scaleScalar', () => {
474
+ it('adds', () => {
475
+ const a = Mat4.identity
476
+ const b = Mat4.identity
477
+ const out = new Mat4()
478
+ a.plus(b, out)
479
+ assert.strictEqual(out[0], 2)
480
+ })
481
+ it('subtracts', () => {
482
+ const out = new Mat4()
483
+ Mat4.identity.minus(Mat4.identity, out)
484
+ for (let i = 0; i < 16; i++) assert.strictEqual(out[i], 0)
485
+ })
486
+ it('scaleScalar', () => {
487
+ const m = Mat4.identity
488
+ const r = m.scaleScalar(3)
489
+ assert.strictEqual(r[0], 3)
490
+ assert.strictEqual(r[5], 3)
491
+ })
492
+ })
493
+
494
+ describe('frob', () => {
495
+ it('frobenius norm of identity', () => {
496
+ closeTo(Mat4.identity.frob(), 2)
497
+ })
498
+ })
499
+
500
+ describe('equals / exactEquals', () => {
501
+ it('exactEquals', () => {
502
+ assert.strictEqual(Mat4.identity.exactEquals(Mat4.identity), true)
503
+ })
504
+ it('equals with epsilon', () => {
505
+ const a = Mat4.identity
506
+ const b = Mat4.identity
507
+ b[0] = 1 + glmaths.EPSILON * 0.1
508
+ assert.strictEqual(a.equals(b), true)
509
+ })
510
+ })
511
+
512
+ describe('toString', () => {
513
+ it('returns string', () => {
514
+ assert.ok(Mat4.identity.toString().includes('mat4'))
515
+ })
516
+ })
517
+
518
+ describe('infinitePerspective', () => {
519
+ it('creates perspective matrix with far at infinity', () => {
520
+ const m = Mat4.infinitePerspective(Math.PI / 4, 16 / 9, 0.1)
521
+ closeTo(m[10], -1)
522
+ closeTo(m[11], -1)
523
+ closeTo(m[14], -0.2)
524
+ })
525
+ })
526
+
527
+ describe('project / unProject', () => {
528
+ it('round-trips through project and unProject', () => {
529
+ const model = Mat4.identity
530
+ const proj = Mat4.perspective(Math.PI / 4, 1, 0.1, 100)
531
+ const viewport = new Vec4(0, 0, 800, 600)
532
+ const point = new Vec3(1, 2, -5)
533
+ const win = Mat4.project(point, model, proj, viewport)
534
+ const back = Mat4.unProject(win, model, proj, viewport)
535
+ assert.notStrictEqual(back, null)
536
+ closeTo(back![0], point[0], 3)
537
+ closeTo(back![1], point[1], 3)
538
+ closeTo(back![2], point[2], 3)
539
+ })
540
+ })
541
+
542
+ describe('factory function', () => {
543
+ it('creates Mat4', () => {
544
+ const m = mat4(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
545
+ assert.ok(m instanceof Mat4)
546
+ })
547
+ })
548
+
549
+ describe('mat * vec operators', () => {
550
+ it('Mat4 * Vec4 scaling + rotation', () => {
551
+ const m = Mat4.fromScaling(new Vec3(2, 3, 4))
552
+ m.rotateX(Math.PI / 3)
553
+ const v = new Vec4(1, 1, 1, 1)
554
+ const expected = v.transformMat4(m, new Vec4())
555
+ const r = m * v
556
+ assert.ok(r instanceof Vec4)
557
+ closeTo(r.x, expected.x)
558
+ closeTo(r.y, expected.y)
559
+ closeTo(r.z, expected.z)
560
+ closeTo(r.w, expected.w)
561
+ })
562
+ it('Mat4 * Vec3 full transform', () => {
563
+ const m = Mat4.identity
564
+ m.scale(new Vec3(2, 2, 2))
565
+ m.rotateZ(Math.PI / 3)
566
+ m.translate(new Vec3(5, 0, 0))
567
+ const v = new Vec3(1, 0, 0)
568
+ const expected = v.transformMat4(m, new Vec3())
569
+ const r = m * v
570
+ assert.ok(r instanceof Vec3)
571
+ closeTo(r.x, expected.x)
572
+ closeTo(r.y, expected.y)
573
+ closeTo(r.z, expected.z)
574
+ })
575
+ it('Mat4 * Vec4 perspective', () => {
576
+ const m = Mat4.perspective(Math.PI / 4, 1, 0.1, 100)
577
+ const v = new Vec4(1, 2, -5, 1)
578
+ const expected = v.transformMat4(m, new Vec4())
579
+ const r = m * v
580
+ closeTo(r.x, expected.x)
581
+ closeTo(r.y, expected.y)
582
+ closeTo(r.z, expected.z)
583
+ closeTo(r.w, expected.w)
584
+ })
585
+ })
586
+ })