@flighthq/geometry 0.3.0 → 0.3.1-next.1041.35b950c

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 (125) hide show
  1. package/dist/aabb.d.ts +1 -0
  2. package/dist/aabb.d.ts.map +1 -1
  3. package/dist/aabb.js +58 -38
  4. package/dist/aabb.js.map +1 -1
  5. package/dist/boundingSphere.d.ts.map +1 -1
  6. package/dist/boundingSphere.js +3 -2
  7. package/dist/boundingSphere.js.map +1 -1
  8. package/dist/capsule.d.ts +6 -5
  9. package/dist/capsule.d.ts.map +1 -1
  10. package/dist/capsule.js +38 -15
  11. package/dist/capsule.js.map +1 -1
  12. package/dist/contract.d.ts +1 -0
  13. package/dist/contract.d.ts.map +1 -1
  14. package/dist/contract.js +1 -0
  15. package/dist/contract.js.map +1 -1
  16. package/dist/enableGeometryPoolGuards.d.ts +4 -0
  17. package/dist/enableGeometryPoolGuards.d.ts.map +1 -0
  18. package/dist/enableGeometryPoolGuards.js +32 -0
  19. package/dist/enableGeometryPoolGuards.js.map +1 -0
  20. package/dist/frustum.d.ts +2 -1
  21. package/dist/frustum.d.ts.map +1 -1
  22. package/dist/frustum.js +4 -1
  23. package/dist/frustum.js.map +1 -1
  24. package/dist/geometryPoolGuards.d.ts +6 -0
  25. package/dist/geometryPoolGuards.d.ts.map +1 -0
  26. package/dist/geometryPoolGuards.js +7 -0
  27. package/dist/geometryPoolGuards.js.map +1 -0
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/matrix.d.ts +12 -9
  33. package/dist/matrix.d.ts.map +1 -1
  34. package/dist/matrix.js +103 -106
  35. package/dist/matrix.js.map +1 -1
  36. package/dist/matrix3.d.ts +17 -2
  37. package/dist/matrix3.d.ts.map +1 -1
  38. package/dist/matrix3.js +88 -44
  39. package/dist/matrix3.js.map +1 -1
  40. package/dist/matrix3Pool.d.ts.map +1 -1
  41. package/dist/matrix3Pool.js +6 -0
  42. package/dist/matrix3Pool.js.map +1 -1
  43. package/dist/matrix4.d.ts +23 -10
  44. package/dist/matrix4.d.ts.map +1 -1
  45. package/dist/matrix4.js +74 -53
  46. package/dist/matrix4.js.map +1 -1
  47. package/dist/matrix4Pool.d.ts.map +1 -1
  48. package/dist/matrix4Pool.js +6 -0
  49. package/dist/matrix4Pool.js.map +1 -1
  50. package/dist/matrixPool.d.ts.map +1 -1
  51. package/dist/matrixPool.js +6 -0
  52. package/dist/matrixPool.js.map +1 -1
  53. package/dist/obb.d.ts +4 -2
  54. package/dist/obb.d.ts.map +1 -1
  55. package/dist/obb.js +164 -88
  56. package/dist/obb.js.map +1 -1
  57. package/dist/quaternion.d.ts +7 -4
  58. package/dist/quaternion.d.ts.map +1 -1
  59. package/dist/quaternion.js +40 -25
  60. package/dist/quaternion.js.map +1 -1
  61. package/dist/quaternionPool.d.ts.map +1 -1
  62. package/dist/quaternionPool.js +6 -0
  63. package/dist/quaternionPool.js.map +1 -1
  64. package/dist/ray3d.d.ts +5 -4
  65. package/dist/ray3d.d.ts.map +1 -1
  66. package/dist/ray3d.js +23 -8
  67. package/dist/ray3d.js.map +1 -1
  68. package/dist/rectangle.d.ts.map +1 -1
  69. package/dist/rectangle.js +66 -43
  70. package/dist/rectangle.js.map +1 -1
  71. package/dist/rectanglePool.d.ts.map +1 -1
  72. package/dist/rectanglePool.js +6 -0
  73. package/dist/rectanglePool.js.map +1 -1
  74. package/dist/transform2d.d.ts.map +1 -1
  75. package/dist/transform2d.js +5 -2
  76. package/dist/transform2d.js.map +1 -1
  77. package/dist/typedarray.js +3 -6
  78. package/dist/typedarray.js.map +1 -1
  79. package/dist/vector2.d.ts.map +1 -1
  80. package/dist/vector2.js +44 -26
  81. package/dist/vector2.js.map +1 -1
  82. package/dist/vector2Pool.d.ts.map +1 -1
  83. package/dist/vector2Pool.js +6 -0
  84. package/dist/vector2Pool.js.map +1 -1
  85. package/dist/vector3.d.ts +2 -2
  86. package/dist/vector3.d.ts.map +1 -1
  87. package/dist/vector3.js +55 -37
  88. package/dist/vector3.js.map +1 -1
  89. package/dist/vector3Pool.d.ts.map +1 -1
  90. package/dist/vector3Pool.js +6 -0
  91. package/dist/vector3Pool.js.map +1 -1
  92. package/dist/vector4.d.ts +2 -2
  93. package/dist/vector4.d.ts.map +1 -1
  94. package/dist/vector4.js +66 -48
  95. package/dist/vector4.js.map +1 -1
  96. package/dist/vector4Pool.d.ts.map +1 -1
  97. package/dist/vector4Pool.js +6 -0
  98. package/dist/vector4Pool.js.map +1 -1
  99. package/package.json +4 -3
  100. package/src/aabb.test.ts +18 -0
  101. package/src/boundingSphere.test.ts +39 -0
  102. package/src/capsule.test.ts +158 -0
  103. package/src/enableGeometryPoolGuards.test.ts +121 -0
  104. package/src/frustum.test.ts +38 -0
  105. package/src/geometryPoolGuards.test.ts +16 -0
  106. package/src/matrix.test.ts +52 -6
  107. package/src/matrix3.test.ts +46 -6
  108. package/src/matrix3Pool.test.ts +11 -0
  109. package/src/matrix4.test.ts +553 -135
  110. package/src/matrix4Pool.test.ts +11 -0
  111. package/src/matrixPool.test.ts +11 -0
  112. package/src/obb.test.ts +277 -0
  113. package/src/plane.test.ts +20 -0
  114. package/src/quaternion.test.ts +222 -4
  115. package/src/quaternionPool.test.ts +12 -0
  116. package/src/ray3d.test.ts +204 -0
  117. package/src/rectangle.test.ts +58 -14
  118. package/src/rectanglePool.test.ts +11 -0
  119. package/src/transform2d.test.ts +38 -0
  120. package/src/vector2.test.ts +170 -0
  121. package/src/vector2Pool.test.ts +11 -0
  122. package/src/vector3.test.ts +210 -0
  123. package/src/vector3Pool.test.ts +11 -0
  124. package/src/vector4.test.ts +231 -0
  125. package/src/vector4Pool.test.ts +11 -0
@@ -26,6 +26,8 @@ import {
26
26
  interpolateMatrix4,
27
27
  inverseMatrix4,
28
28
  isAffineMatrix4,
29
+ getVector3Dot,
30
+ getVector3Length,
29
31
  matrix4TransformPoint,
30
32
  matrix4TransformVector,
31
33
  matrix4TransformVectors,
@@ -49,6 +51,7 @@ import {
49
51
  setOrthographicMatrix4,
50
52
  setPerspectiveMatrix4,
51
53
  setQuaternionFromAxisAngle,
54
+ setVector3,
52
55
  translateMatrix4,
53
56
  transposeMatrix4,
54
57
  writeMatrix4ToFloat32Array,
@@ -59,32 +62,55 @@ const X_AXIS = { x: 1, y: 0, z: 0, w: 0 };
59
62
  const Y_AXIS = { x: 0, y: 1, z: 0, w: 0 };
60
63
  const Z_AXIS = { x: 0, y: 0, z: 1, w: 0 };
61
64
 
65
+ // Rotation AND non-uniform scale AND translation together. A diagonal matrix, a uniform scale, or a
66
+ // zero translation each make two different compositions agree — that coincidence is what hid the
67
+ // inverted operands from the previous suite, so every composition test below starts from this.
68
+ function discriminatingMatrix4(): Matrix4 {
69
+ const m = createMatrix4();
70
+ setMatrix4(m, 0, 2, 0, 0, -3, 0, 0, 0, 0, 0, 4, 0, 7, 8, 9, 1);
71
+ return m;
72
+ }
73
+
62
74
  describe('appendMatrix4', () => {
63
- it('is equivalent to multiply(out, source, other)', () => {
64
- const a = createMatrix4();
65
- translateMatrix4(a, a, 5, 0, 0);
66
- const b = createMatrix4();
67
- scaleMatrix4(b, b, 2, 2, 2);
75
+ // OpenFL's Matrix3D.append composes `other` so it applies AFTER everything already in `source`,
76
+ // which under Flight's column-vector convention is the LEFT operand: out = other · source. Asserting
77
+ // against a hand-built product rather than a captured result is what keeps this readable as algebra.
78
+ it('composes other after source: out = other · source', () => {
79
+ const source = discriminatingMatrix4();
80
+ const other = createMatrix4();
81
+ setMatrix4(other, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 0, 0, 1);
82
+ const expected = createMatrix4();
83
+ multiplyMatrix4(expected, other, source);
84
+
85
+ const out = createMatrix4();
86
+ appendMatrix4(out, source, other);
68
87
 
69
- const out1 = createMatrix4();
70
- appendMatrix4(out1, a, b);
88
+ expect(Array.from(out.m)).toEqual(Array.from(expected.m));
89
+ });
90
+
91
+ it('is not the other operand order', () => {
92
+ const source = discriminatingMatrix4();
93
+ const other = createMatrix4();
94
+ setMatrix4(other, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 0, 0, 1);
95
+ const transposedOrder = createMatrix4();
96
+ multiplyMatrix4(transposedOrder, source, other);
71
97
 
72
- const out2 = createMatrix4();
73
- multiplyMatrix4(out2, a, b);
98
+ const out = createMatrix4();
99
+ appendMatrix4(out, source, other);
74
100
 
75
- expect(equalsMatrix4(out1, out2)).toBe(true);
101
+ expect(Array.from(out.m)).not.toEqual(Array.from(transposedOrder.m));
76
102
  });
77
103
 
78
104
  it('supports out === source', () => {
79
- const a = createMatrix4();
80
- translateMatrix4(a, a, 5, 0, 0);
81
- const b = createMatrix4();
82
- scaleMatrix4(b, b, 2, 3, 4);
105
+ const source = discriminatingMatrix4();
106
+ const other = createMatrix4();
107
+ scaleMatrix4(other, other, 2, 3, 4);
83
108
  const expected = createMatrix4();
84
- appendMatrix4(expected, a, b);
109
+ appendMatrix4(expected, source, other);
110
+
111
+ appendMatrix4(source, source, other);
85
112
 
86
- appendMatrix4(a, a, b);
87
- expect(equalsMatrix4(a, expected)).toBe(true);
113
+ expect(Array.from(source.m)).toEqual(Array.from(expected.m));
88
114
  });
89
115
  });
90
116
 
@@ -100,95 +126,173 @@ describe('appendRotationMatrix4', () => {
100
126
  expect(m.m[5]).toBeCloseTo(0);
101
127
  });
102
128
 
103
- it('does not rotate existing translation when appending rotation', () => {
129
+ // A world-frame rotation turns the space the matrix sits in, so the existing translation sweeps
130
+ // around the origin with it. Before the operand fix this function left the translation in place,
131
+ // which is the local behavior and belonged to prependRotationMatrix4.
132
+ it('sweeps the existing translation around the origin', () => {
104
133
  const m = createMatrix4();
105
134
  translateMatrix4(m, m, 10, 0, 0);
106
135
 
107
136
  appendRotationMatrix4(m, m, Math.PI / 2, Z_AXIS);
108
137
 
109
- expect(m.m[12]).toBe(10);
110
- expect(m.m[13]).toBe(0);
138
+ expect(m.m[12]).toBeCloseTo(0, 6);
139
+ expect(m.m[13]).toBeCloseTo(10, 6);
111
140
  });
112
141
 
113
- it('rotates around pivot point', () => {
114
- const m = createMatrix4();
115
- translateMatrix4(m, m, 10, 0, 0);
142
+ it('applies the rotation after source: out = R · source', () => {
143
+ const source = discriminatingMatrix4();
144
+ const rotation = createMatrix4();
145
+ appendRotationMatrix4(rotation, rotation, Math.PI / 3, Z_AXIS);
146
+ const expected = createMatrix4();
147
+ multiplyMatrix4(expected, rotation, source);
116
148
 
117
- appendRotationMatrix4(m, m, Math.PI / 2, Z_AXIS, { x: 5, y: 0, z: 0, w: 1 });
149
+ const out = createMatrix4();
150
+ appendRotationMatrix4(out, source, Math.PI / 3, Z_AXIS);
118
151
 
119
- expect(m.m[12]).toBeCloseTo(5);
120
- expect(m.m[13]).toBeCloseTo(5);
152
+ for (let i = 0; i < 16; i++) expect(out.m[i]).toBeCloseTo(expected.m[i], 5);
121
153
  });
122
154
 
123
- it('appendRotation and prependRotation match on identity', () => {
124
- const a = createMatrix4();
125
- const b = createMatrix4();
155
+ // The pivot is the point the rotation holds still. Asserting that property rather than a captured
156
+ // translation is what distinguishes a correct composite from the transposed T(-p)·R·T(p) this
157
+ // function used to build — the old test asserted the inverted numbers and so could never fail.
158
+ it('holds the pivot point fixed', () => {
159
+ const source = createMatrix4();
160
+ translateMatrix4(source, source, 10, 0, 0);
161
+ const pivot = { x: 5, y: 0, z: 0, w: 1 };
126
162
 
127
- appendRotationMatrix4(a, a, Math.PI / 4, Z_AXIS);
128
- prependRotationMatrix4(b, b, Math.PI / 4, Z_AXIS);
163
+ const out = createMatrix4();
164
+ appendRotationMatrix4(out, source, Math.PI / 2, Z_AXIS, pivot);
129
165
 
130
- expect(equalsMatrix4(a, b)).toBe(true);
166
+ // A world-frame rotation about `pivot` fixes the world point `pivot`, so the source point that
167
+ // already lands there must still land there.
168
+ const landsOnPivot = createVector3(-5, 0, 0);
169
+ const before = createVector3();
170
+ matrix4TransformPoint(before, source, landsOnPivot);
171
+ expect(before.x).toBeCloseTo(pivot.x, 6);
172
+
173
+ const after = createVector3();
174
+ matrix4TransformPoint(after, out, landsOnPivot);
175
+ expect(after.x).toBeCloseTo(pivot.x, 6);
176
+ expect(after.y).toBeCloseTo(pivot.y, 6);
177
+ expect(after.z).toBeCloseTo(pivot.z, 6);
178
+ });
179
+
180
+ it('differs from the local prependRotationMatrix4 on a translated matrix', () => {
181
+ const source = discriminatingMatrix4();
182
+ const world = createMatrix4();
183
+ const local = createMatrix4();
184
+
185
+ appendRotationMatrix4(world, source, Math.PI / 3, Z_AXIS);
186
+ prependRotationMatrix4(local, source, Math.PI / 3, Z_AXIS);
187
+
188
+ expect(Array.from(world.m)).not.toEqual(Array.from(local.m));
189
+ });
190
+
191
+ it('writes the same values whether out aliases source or not', () => {
192
+ const source = discriminatingMatrix4();
193
+ const distinct = createMatrix4();
194
+ const aliased = cloneMatrix4(source);
195
+
196
+ appendRotationMatrix4(distinct, source, Math.PI / 3, Z_AXIS, { x: 1, y: 2, z: 3, w: 1 });
197
+ appendRotationMatrix4(aliased, aliased, Math.PI / 3, Z_AXIS, { x: 1, y: 2, z: 3, w: 1 });
198
+
199
+ expect(Array.from(aliased.m)).toEqual(Array.from(distinct.m));
131
200
  });
132
201
  });
133
202
 
134
203
  describe('appendScaleMatrix4', () => {
135
- it('scales an identity matrix', () => {
136
- const m = createMatrix4();
204
+ // Inherits its composition from appendMatrix4 — it is not implemented separately, so this test also
205
+ // pins that the wrapper was corrected by inheritance rather than by hand.
206
+ it('applies the scale after source: out = S · source', () => {
207
+ const source = discriminatingMatrix4();
208
+ const scale = createMatrix4();
209
+ setMatrix4(scale, 2, 0, 0, 0, 0, 3, 0, 0, 0, 0, 4, 0, 0, 0, 0, 1);
210
+ const expected = createMatrix4();
211
+ multiplyMatrix4(expected, scale, source);
137
212
 
138
- appendScaleMatrix4(m, m, 2, 3, 4);
213
+ const out = createMatrix4();
214
+ appendScaleMatrix4(out, source, 2, 3, 4);
139
215
 
140
- expect(m.m[0]).toBe(2);
141
- expect(m.m[5]).toBe(3);
142
- expect(m.m[10]).toBe(4);
216
+ expect(Array.from(out.m)).toEqual(Array.from(expected.m));
143
217
  });
144
218
 
145
- it('accumulates scale multiplicatively', () => {
146
- const m = createMatrix4();
147
- scaleMatrix4(m, m, 2, 2, 2);
219
+ // A world-frame scale reaches the translation; the local one does not. This is the pair that was
220
+ // indistinguishable before the operand fix, because both computed source · S.
221
+ it('scales the translation, unlike the local scaleMatrix4', () => {
222
+ const source = discriminatingMatrix4();
223
+ const world = createMatrix4();
224
+ const local = createMatrix4();
148
225
 
149
- appendScaleMatrix4(m, m, 3, 4, 5);
226
+ appendScaleMatrix4(world, source, 2, 3, 4);
227
+ scaleMatrix4(local, source, 2, 3, 4);
150
228
 
151
- expect(m.m[0]).toBe(6);
152
- expect(m.m[5]).toBe(8);
153
- expect(m.m[10]).toBe(10);
229
+ expect(world.m[12]).toBe(source.m[12] * 2);
230
+ expect(local.m[12]).toBe(source.m[12]);
231
+ expect(Array.from(world.m)).not.toEqual(Array.from(local.m));
232
+ });
233
+
234
+ it('supports out === source', () => {
235
+ const source = discriminatingMatrix4();
236
+ const expected = createMatrix4();
237
+ appendScaleMatrix4(expected, source, 2, 3, 4);
238
+
239
+ appendScaleMatrix4(source, source, 2, 3, 4);
240
+
241
+ expect(Array.from(source.m)).toEqual(Array.from(expected.m));
154
242
  });
155
243
  });
156
244
 
157
245
  describe('appendTranslationMatrix4', () => {
158
- it('adds translation to an identity matrix', () => {
159
- const m = createMatrix4();
246
+ // The one member of the family that was hand-ported rather than routed through appendMatrix4, which
247
+ // is why it alone stayed correct while the wrapper was inverted. out = T · source, i.e. the offset is
248
+ // a bare sum on the translation and the basis is untouched.
249
+ it('adds the offset to the translation and leaves the basis alone', () => {
250
+ const source = discriminatingMatrix4();
251
+ const out = createMatrix4();
160
252
 
161
- appendTranslationMatrix4(m, m, 1, 2, 3);
253
+ appendTranslationMatrix4(out, source, 1, 2, 3);
162
254
 
163
- expect(m.m[12]).toBe(1);
164
- expect(m.m[13]).toBe(2);
165
- expect(m.m[14]).toBe(3);
255
+ expect(out.m[12]).toBe(source.m[12] + 1);
256
+ expect(out.m[13]).toBe(source.m[13] + 2);
257
+ expect(out.m[14]).toBe(source.m[14] + 3);
258
+ expect(Array.from(out.m).slice(0, 12)).toEqual(Array.from(source.m).slice(0, 12));
166
259
  });
167
260
 
168
- it('adds to existing translation values', () => {
169
- const m = createMatrix4();
170
- m.m[12] = 10;
171
- m.m[13] = 20;
172
- m.m[14] = 30;
261
+ it('equals composing a translation matrix after source', () => {
262
+ const source = discriminatingMatrix4();
263
+ const translation = createMatrix4();
264
+ setMatrix4(translation, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 2, 3, 1);
265
+ const expected = createMatrix4();
266
+ multiplyMatrix4(expected, translation, source);
173
267
 
174
- appendTranslationMatrix4(m, m, 1, 2, 3);
268
+ const out = createMatrix4();
269
+ appendTranslationMatrix4(out, source, 1, 2, 3);
175
270
 
176
- expect(m.m[12]).toBe(11);
177
- expect(m.m[13]).toBe(22);
178
- expect(m.m[14]).toBe(33);
271
+ expect(Array.from(out.m)).toEqual(Array.from(expected.m));
179
272
  });
180
273
 
181
- it('does not affect rotation or scale components', () => {
182
- const m = createMatrix4();
183
- m.m[0] = 2; // scale x
184
- m.m[5] = 3; // scale y
185
- m.m[10] = 4; // scale z
274
+ it('differs from the local prependTranslationMatrix4 on a rotated matrix', () => {
275
+ const source = discriminatingMatrix4();
276
+ const world = createMatrix4();
277
+ const local = createMatrix4();
186
278
 
187
- appendTranslationMatrix4(m, m, 1, 2, 3);
279
+ appendTranslationMatrix4(world, source, 1, 2, 3);
280
+ prependTranslationMatrix4(local, source, 1, 2, 3);
188
281
 
189
- expect(m.m[0]).toBe(2);
190
- expect(m.m[5]).toBe(3);
191
- expect(m.m[10]).toBe(4);
282
+ expect(Array.from(world.m)).not.toEqual(Array.from(local.m));
283
+ });
284
+
285
+ it('copies the whole source matrix when out is a distinct object', () => {
286
+ const source = discriminatingMatrix4();
287
+ const before = Array.from(source.m);
288
+ const out = createMatrix4(9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9);
289
+ const aliased = cloneMatrix4(source);
290
+
291
+ appendTranslationMatrix4(out, source, 1, 2, 3);
292
+ appendTranslationMatrix4(aliased, aliased, 1, 2, 3);
293
+
294
+ expect(Array.from(out.m)).toEqual(Array.from(aliased.m));
295
+ expect(Array.from(source.m)).toEqual(before);
192
296
  });
193
297
  });
194
298
 
@@ -281,6 +385,14 @@ describe('copyMatrix4', () => {
281
385
  expect(source.m[0]).toBe(1);
282
386
  expect(out.m[0]).toBe(99);
283
387
  });
388
+ it('is a no-op when out aliases source', () => {
389
+ const m = createMatrix4();
390
+ setMatrix4(m, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 7, 8, 9, 1);
391
+
392
+ copyMatrix4(m, m);
393
+
394
+ expect(Array.from(m.m)).toEqual([0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 7, 8, 9, 1]);
395
+ });
284
396
  });
285
397
 
286
398
  describe('copyMatrix4ColumnFromVector4', () => {
@@ -315,6 +427,23 @@ describe('copyMatrix4ColumnFromVector4', () => {
315
427
  expect(() => copyMatrix4ColumnFromVector4(m, -1, v)).toThrow(RangeError);
316
428
  expect(() => copyMatrix4ColumnFromVector4(m, 4, v)).toThrow(RangeError);
317
429
  });
430
+
431
+ // Column c occupies elements 4c..4c+3 and nothing else moves. Asserting the whole matrix per
432
+ // column catches a row/column transposition or a stray write that a single-index check misses.
433
+ it('writes column c into elements 4c..4c+3 for every column', () => {
434
+ for (let column = 0; column < 4; column++) {
435
+ const m = createMatrix4(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
436
+
437
+ copyMatrix4ColumnFromVector4(m, column, { x: 1, y: 2, z: 3, w: 4 });
438
+
439
+ const expected = new Array<number>(16).fill(0);
440
+ expected[column * 4] = 1;
441
+ expected[column * 4 + 1] = 2;
442
+ expected[column * 4 + 2] = 3;
443
+ expected[column * 4 + 3] = 4;
444
+ expect(Array.from(m.m)).toEqual(expected);
445
+ }
446
+ });
318
447
  });
319
448
 
320
449
  describe('copyMatrix4ColumnToVector4', () => {
@@ -338,6 +467,24 @@ describe('copyMatrix4ColumnToVector4', () => {
338
467
  const out = { x: 0, y: 0, z: 0, w: 0 };
339
468
 
340
469
  expect(() => copyMatrix4ColumnToVector4(out, 99, m)).toThrow(RangeError);
470
+ expect(() => copyMatrix4ColumnToVector4(out, -1, m)).toThrow(RangeError);
471
+ });
472
+
473
+ it('reads column c from elements 4c..4c+3 for every column', () => {
474
+ const m = createMatrix4(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16);
475
+
476
+ for (let column = 0; column < 4; column++) {
477
+ const out = { x: 0, y: 0, z: 0, w: 0 };
478
+
479
+ copyMatrix4ColumnToVector4(out, column, m);
480
+
481
+ expect(out).toEqual({
482
+ x: column * 4 + 1,
483
+ y: column * 4 + 2,
484
+ z: column * 4 + 3,
485
+ w: column * 4 + 4,
486
+ });
487
+ }
341
488
  });
342
489
  });
343
490
 
@@ -373,6 +520,23 @@ describe('copyMatrix4RowFromVector4', () => {
373
520
  expect(() => copyMatrix4RowFromVector4(m, -1, v)).toThrow(RangeError);
374
521
  expect(() => copyMatrix4RowFromVector4(m, 4, v)).toThrow(RangeError);
375
522
  });
523
+
524
+ // Row r occupies elements r, r+4, r+8, r+12 — the stride that distinguishes a row write from a
525
+ // column write in column-major storage.
526
+ it('writes row r with a stride of four for every row', () => {
527
+ for (let row = 0; row < 4; row++) {
528
+ const m = createMatrix4(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
529
+
530
+ copyMatrix4RowFromVector4(m, row, { x: 1, y: 2, z: 3, w: 4 });
531
+
532
+ const expected = new Array<number>(16).fill(0);
533
+ expected[row] = 1;
534
+ expected[row + 4] = 2;
535
+ expected[row + 8] = 3;
536
+ expected[row + 12] = 4;
537
+ expect(Array.from(m.m)).toEqual(expected);
538
+ }
539
+ });
376
540
  });
377
541
 
378
542
  describe('copyMatrix4RowToVector4', () => {
@@ -396,6 +560,19 @@ describe('copyMatrix4RowToVector4', () => {
396
560
  const out = { x: 0, y: 0, z: 0, w: 0 };
397
561
 
398
562
  expect(() => copyMatrix4RowToVector4(out, 42, m)).toThrow(RangeError);
563
+ expect(() => copyMatrix4RowToVector4(out, -1, m)).toThrow(RangeError);
564
+ });
565
+
566
+ it('reads row r with a stride of four for every row', () => {
567
+ const m = createMatrix4(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16);
568
+
569
+ for (let row = 0; row < 4; row++) {
570
+ const out = { x: 0, y: 0, z: 0, w: 0 };
571
+
572
+ copyMatrix4RowToVector4(out, row, m);
573
+
574
+ expect(out).toEqual({ x: row + 1, y: row + 5, z: row + 9, w: row + 13 });
575
+ }
399
576
  });
400
577
  });
401
578
 
@@ -519,6 +696,71 @@ describe('decomposeMatrix4', () => {
519
696
  expect(s.z).toBeCloseTo(1, 6);
520
697
  expect(r.w).toBeCloseTo(1, 6);
521
698
  });
699
+
700
+ // Quaternion extraction picks one of four formulas by which diagonal term dominates. A half turn
701
+ // about each axis drives the trace negative and selects a different one, so recomposing from the
702
+ // result is the check that all four agree with composeMatrix4 rather than only the trace case.
703
+ it.each([
704
+ ['x', createVector3(1, 0, 0)],
705
+ ['y', createVector3(0, 1, 0)],
706
+ ['z', createVector3(0, 0, 1)],
707
+ ])('round-trips a half turn about %s', (_axis, axis) => {
708
+ const rotation = createQuaternion();
709
+ setQuaternionFromAxisAngle(rotation, axis, Math.PI);
710
+ const original = createMatrix4();
711
+ composeMatrix4(original, createVector3(5, 6, 7), rotation, createVector3(2, 3, 4));
712
+
713
+ const p = createVector3();
714
+ const r = createQuaternion();
715
+ const s = createVector3();
716
+ decomposeMatrix4(p, r, s, original);
717
+ const recomposed = createMatrix4();
718
+ composeMatrix4(recomposed, p, r, s);
719
+
720
+ for (let i = 0; i < 16; i++) {
721
+ expect(recomposed.m[i]).toBeCloseTo(original.m[i], 5);
722
+ }
723
+ });
724
+
725
+ it('folds a mirrored basis into a negative x scale', () => {
726
+ const original = createMatrix4();
727
+ composeMatrix4(original, createVector3(0, 0, 0), createQuaternion(), createVector3(-2, 3, 4));
728
+
729
+ const p = createVector3();
730
+ const r = createQuaternion();
731
+ const s = createVector3();
732
+ decomposeMatrix4(p, r, s, original);
733
+
734
+ expect(s.x).toBeCloseTo(-2, 5);
735
+ expect(s.y).toBeCloseTo(3, 5);
736
+ expect(s.z).toBeCloseTo(4, 5);
737
+ const recomposed = createMatrix4();
738
+ composeMatrix4(recomposed, p, r, s);
739
+ for (let i = 0; i < 16; i++) {
740
+ expect(recomposed.m[i]).toBeCloseTo(original.m[i], 5);
741
+ }
742
+ });
743
+
744
+ // A collapsed axis has no recoverable direction, so the guard substitutes zero rather than
745
+ // dividing by it. The contract is that the caller gets finite numbers and an honest zero scale.
746
+ it('reports a collapsed axis as zero scale without producing NaN', () => {
747
+ const original = createMatrix4();
748
+ composeMatrix4(original, createVector3(1, 2, 3), createQuaternion(), createVector3(0, 0, 0));
749
+
750
+ const p = createVector3();
751
+ const r = createQuaternion();
752
+ const s = createVector3();
753
+ decomposeMatrix4(p, r, s, original);
754
+
755
+ expect(s.x).toBe(0);
756
+ expect(s.y).toBe(0);
757
+ expect(s.z).toBe(0);
758
+ expect(Number.isFinite(r.x)).toBe(true);
759
+ expect(Number.isFinite(r.y)).toBe(true);
760
+ expect(Number.isFinite(r.z)).toBe(true);
761
+ expect(Number.isFinite(r.w)).toBe(true);
762
+ expect(p.x).toBe(1);
763
+ });
522
764
  });
523
765
 
524
766
  describe('equalsMatrix4', () => {
@@ -978,7 +1220,7 @@ describe('multiplyMatrix4', () => {
978
1220
  expect(equalsMatrix4(m1, m2)).toBe(true);
979
1221
  });
980
1222
 
981
- it('prependScale equals multiply scale × matrix', () => {
1223
+ it('prependScale equals multiplying the scale on the right', () => {
982
1224
  const m1 = createMatrix4();
983
1225
  translateMatrix4(m1, m1, 10, 0, 0);
984
1226
  const m2 = createMatrix4();
@@ -988,7 +1230,7 @@ describe('multiplyMatrix4', () => {
988
1230
  scaleMatrix4(s, s, 2, 2, 2);
989
1231
 
990
1232
  prependScaleMatrix4(m1, m1, 2, 2, 2);
991
- multiplyMatrix4(m2, s, m2);
1233
+ multiplyMatrix4(m2, m2, s);
992
1234
 
993
1235
  expect(equalsMatrix4(m1, m2)).toBe(true);
994
1236
  });
@@ -996,31 +1238,44 @@ describe('multiplyMatrix4', () => {
996
1238
  });
997
1239
 
998
1240
  describe('prependMatrix4', () => {
999
- it('is equivalent to multiply(out, other, source)', () => {
1000
- const a = createMatrix4();
1001
- translateMatrix4(a, a, 5, 0, 0);
1002
- const b = createMatrix4();
1003
- scaleMatrix4(b, b, 2, 2, 2);
1241
+ // The mirror of append: `other` applies BEFORE everything already in `source`, so it is the RIGHT
1242
+ // operand — out = source · other.
1243
+ it('composes other before source: out = source · other', () => {
1244
+ const source = discriminatingMatrix4();
1245
+ const other = createMatrix4();
1246
+ setMatrix4(other, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 0, 0, 1);
1247
+ const expected = createMatrix4();
1248
+ multiplyMatrix4(expected, source, other);
1004
1249
 
1005
- const out1 = createMatrix4();
1006
- prependMatrix4(out1, a, b);
1250
+ const out = createMatrix4();
1251
+ prependMatrix4(out, source, other);
1252
+
1253
+ expect(Array.from(out.m)).toEqual(Array.from(expected.m));
1254
+ });
1007
1255
 
1008
- const out2 = createMatrix4();
1009
- multiplyMatrix4(out2, b, a);
1256
+ it('is the opposite composition from appendMatrix4', () => {
1257
+ const source = discriminatingMatrix4();
1258
+ const other = createMatrix4();
1259
+ setMatrix4(other, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 0, 0, 1);
1010
1260
 
1011
- expect(equalsMatrix4(out1, out2)).toBe(true);
1261
+ const appended = createMatrix4();
1262
+ appendMatrix4(appended, source, other);
1263
+ const prepended = createMatrix4();
1264
+ prependMatrix4(prepended, source, other);
1265
+
1266
+ expect(Array.from(appended.m)).not.toEqual(Array.from(prepended.m));
1012
1267
  });
1013
1268
 
1014
1269
  it('supports out === source', () => {
1015
- const a = createMatrix4();
1016
- translateMatrix4(a, a, 5, 0, 0);
1017
- const b = createMatrix4();
1018
- scaleMatrix4(b, b, 2, 3, 4);
1270
+ const source = discriminatingMatrix4();
1271
+ const other = createMatrix4();
1272
+ scaleMatrix4(other, other, 2, 3, 4);
1019
1273
  const expected = createMatrix4();
1020
- prependMatrix4(expected, a, b);
1274
+ prependMatrix4(expected, source, other);
1021
1275
 
1022
- prependMatrix4(a, a, b);
1023
- expect(equalsMatrix4(a, expected)).toBe(true);
1276
+ prependMatrix4(source, source, other);
1277
+
1278
+ expect(Array.from(source.m)).toEqual(Array.from(expected.m));
1024
1279
  });
1025
1280
  });
1026
1281
 
@@ -1034,86 +1289,130 @@ describe('prependRotationMatrix4', () => {
1034
1289
  expect(m.m[1]).toBeCloseTo(1);
1035
1290
  });
1036
1291
 
1037
- it('rotates translation when prepending rotation', () => {
1292
+ // A local rotation turns the object about its own axes, so its position in the outer space is
1293
+ // unchanged. Before the operand fix this function swept the translation, which is append's job.
1294
+ it('leaves the existing translation in place', () => {
1038
1295
  const m = createMatrix4();
1039
1296
  translateMatrix4(m, m, 10, 0, 0);
1040
1297
 
1041
1298
  prependRotationMatrix4(m, m, Math.PI / 2, Z_AXIS);
1042
1299
 
1043
- // (10, 0, 0) → (0, 10, 0)
1044
- expect(m.m[12]).toBeCloseTo(0);
1045
- expect(m.m[13]).toBeCloseTo(10);
1300
+ expect(m.m[12]).toBeCloseTo(10, 6);
1301
+ expect(m.m[13]).toBeCloseTo(0, 6);
1302
+ });
1303
+
1304
+ it('applies the rotation before source: out = source · R', () => {
1305
+ const source = discriminatingMatrix4();
1306
+ const rotation = createMatrix4();
1307
+ prependRotationMatrix4(rotation, rotation, Math.PI / 3, Z_AXIS);
1308
+ const expected = createMatrix4();
1309
+ multiplyMatrix4(expected, source, rotation);
1310
+
1311
+ const out = createMatrix4();
1312
+ prependRotationMatrix4(out, source, Math.PI / 3, Z_AXIS);
1313
+
1314
+ for (let i = 0; i < 16; i++) expect(out.m[i]).toBeCloseTo(expected.m[i], 5);
1315
+ });
1316
+
1317
+ it('holds the pivot point fixed', () => {
1318
+ const source = discriminatingMatrix4();
1319
+ const pivot = { x: 5, y: 0, z: 0, w: 1 };
1320
+ const before = createVector3();
1321
+ matrix4TransformPoint(before, source, createVector3(pivot.x, pivot.y, pivot.z));
1322
+
1323
+ const out = createMatrix4();
1324
+ prependRotationMatrix4(out, source, Math.PI / 2, Z_AXIS, pivot);
1325
+
1326
+ const after = createVector3();
1327
+ matrix4TransformPoint(after, out, createVector3(pivot.x, pivot.y, pivot.z));
1328
+ expect(after.x).toBeCloseTo(before.x, 5);
1329
+ expect(after.y).toBeCloseTo(before.y, 5);
1330
+ expect(after.z).toBeCloseTo(before.z, 5);
1331
+ });
1332
+
1333
+ it('writes the same values whether out aliases source or not', () => {
1334
+ const source = discriminatingMatrix4();
1335
+ const distinct = createMatrix4();
1336
+ const aliased = cloneMatrix4(source);
1337
+
1338
+ prependRotationMatrix4(distinct, source, Math.PI / 3, Z_AXIS, { x: 1, y: 2, z: 3, w: 1 });
1339
+ prependRotationMatrix4(aliased, aliased, Math.PI / 3, Z_AXIS, { x: 1, y: 2, z: 3, w: 1 });
1340
+
1341
+ expect(Array.from(aliased.m)).toEqual(Array.from(distinct.m));
1046
1342
  });
1047
1343
  });
1048
1344
 
1049
1345
  describe('prependScaleMatrix4', () => {
1050
- it('scales an identity matrix', () => {
1051
- const m = createMatrix4();
1346
+ it('applies the scale before source: out = source · S', () => {
1347
+ const source = discriminatingMatrix4();
1348
+ const scale = createMatrix4();
1349
+ setMatrix4(scale, 2, 0, 0, 0, 0, 3, 0, 0, 0, 0, 4, 0, 0, 0, 0, 1);
1350
+ const expected = createMatrix4();
1351
+ multiplyMatrix4(expected, source, scale);
1052
1352
 
1053
- prependScaleMatrix4(m, m, 2, 3, 4);
1353
+ const out = createMatrix4();
1354
+ prependScaleMatrix4(out, source, 2, 3, 4);
1054
1355
 
1055
- expect(m.m[0]).toBe(2);
1056
- expect(m.m[5]).toBe(3);
1057
- expect(m.m[10]).toBe(4);
1356
+ expect(Array.from(out.m)).toEqual(Array.from(expected.m));
1058
1357
  });
1059
1358
 
1060
- it('scales translation when prepending scale', () => {
1061
- const m = createMatrix4();
1062
- translateMatrix4(m, m, 10, 20, 30);
1359
+ it('leaves the translation alone, unlike the world-frame appendScaleMatrix4', () => {
1360
+ const source = discriminatingMatrix4();
1361
+ const out = createMatrix4();
1063
1362
 
1064
- prependScaleMatrix4(m, m, 2, 3, 4);
1363
+ prependScaleMatrix4(out, source, 2, 3, 4);
1065
1364
 
1066
- expect(m.m[12]).toBe(20); // 10 * 2
1067
- expect(m.m[13]).toBe(60); // 20 * 3
1068
- expect(m.m[14]).toBe(120); // 30 * 4
1365
+ expect(out.m[12]).toBe(source.m[12]);
1366
+ expect(out.m[13]).toBe(source.m[13]);
1367
+ expect(out.m[14]).toBe(source.m[14]);
1069
1368
  });
1070
1369
 
1071
- it('scale, appendScale, and prependScale behave the same on identity', () => {
1072
- const a = createMatrix4();
1073
- const b = createMatrix4();
1074
- const c = createMatrix4();
1370
+ it('supports out === source', () => {
1371
+ const source = discriminatingMatrix4();
1372
+ const expected = createMatrix4();
1373
+ prependScaleMatrix4(expected, source, 2, 3, 4);
1075
1374
 
1076
- scaleMatrix4(a, a, 2, 3, 4);
1077
- appendScaleMatrix4(b, b, 2, 3, 4);
1078
- prependScaleMatrix4(c, c, 2, 3, 4);
1375
+ prependScaleMatrix4(source, source, 2, 3, 4);
1079
1376
 
1080
- expect(equalsMatrix4(a, b)).toBe(true);
1081
- expect(equalsMatrix4(b, c)).toBe(true);
1377
+ expect(Array.from(source.m)).toEqual(Array.from(expected.m));
1082
1378
  });
1083
1379
  });
1084
1380
 
1085
1381
  describe('prependTranslationMatrix4', () => {
1086
- it('translates an identity matrix by (x, y, z)', () => {
1087
- const m = createMatrix4();
1382
+ it('applies the offset before source: out = source · T', () => {
1383
+ const source = discriminatingMatrix4();
1384
+ const translation = createMatrix4();
1385
+ setMatrix4(translation, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 2, 3, 1);
1386
+ const expected = createMatrix4();
1387
+ multiplyMatrix4(expected, source, translation);
1088
1388
 
1089
- prependTranslationMatrix4(m, m, 1, 2, 3);
1389
+ const out = createMatrix4();
1390
+ prependTranslationMatrix4(out, source, 1, 2, 3);
1090
1391
 
1091
- expect(m.m[12]).toBe(1);
1092
- expect(m.m[13]).toBe(2);
1093
- expect(m.m[14]).toBe(3);
1392
+ expect(Array.from(out.m)).toEqual(Array.from(expected.m));
1094
1393
  });
1095
1394
 
1096
- it('prepends translation before existing transforms', () => {
1097
- const m = createMatrix4();
1098
- translateMatrix4(m, m, 10, 0, 0);
1395
+ // The local frame carries the offset through source's own basis, so a rotated, scaled source turns
1396
+ // and stretches it — a bare sum would be the world-frame answer.
1397
+ it('carries the offset through the source basis', () => {
1398
+ const source = discriminatingMatrix4();
1399
+ const out = createMatrix4();
1099
1400
 
1100
- prependTranslationMatrix4(m, m, 5, 0, 0);
1401
+ prependTranslationMatrix4(out, source, 1, 2, 3);
1101
1402
 
1102
- // world-space prepend: (5 + 10)
1103
- expect(m.m[12]).toBe(15);
1403
+ expect(out.m[12]).toBe(source.m[0] * 1 + source.m[4] * 2 + source.m[8] * 3 + source.m[12]);
1404
+ expect(out.m[13]).toBe(source.m[1] * 1 + source.m[5] * 2 + source.m[9] * 3 + source.m[13]);
1405
+ expect(out.m[14]).toBe(source.m[2] * 1 + source.m[6] * 2 + source.m[10] * 3 + source.m[14]);
1104
1406
  });
1105
1407
 
1106
- it('translate, appendTranslation, and prependTranslation behave the same on identity', () => {
1107
- const a = createMatrix4();
1108
- const b = createMatrix4();
1109
- const c = createMatrix4();
1408
+ it('supports out === source', () => {
1409
+ const source = discriminatingMatrix4();
1410
+ const expected = createMatrix4();
1411
+ prependTranslationMatrix4(expected, source, 1, 2, 3);
1110
1412
 
1111
- translateMatrix4(a, a, 1, 2, 3);
1112
- appendTranslationMatrix4(b, b, 1, 2, 3);
1113
- prependTranslationMatrix4(c, c, 1, 2, 3);
1413
+ prependTranslationMatrix4(source, source, 1, 2, 3);
1114
1414
 
1115
- expect(equalsMatrix4(a, b)).toBe(true);
1116
- expect(equalsMatrix4(b, c)).toBe(true);
1415
+ expect(Array.from(source.m)).toEqual(Array.from(expected.m));
1117
1416
  });
1118
1417
  });
1119
1418
 
@@ -1128,6 +1427,23 @@ describe('rotateMatrix4', () => {
1128
1427
  expect(equalsMatrix4(a, b)).toBe(true);
1129
1428
  });
1130
1429
 
1430
+ it('matches the quaternion rotation path for an asymmetric unit axis', () => {
1431
+ const inverseLength = 1 / Math.sqrt(14);
1432
+ const axis = createVector3(2 * inverseLength, inverseLength, 3 * inverseLength);
1433
+ const radians = Math.PI / 3;
1434
+ const quaternion = createQuaternion();
1435
+ setQuaternionFromAxisAngle(quaternion, axis, radians);
1436
+ const expected = createMatrix4();
1437
+ setMatrix4FromQuaternion(expected, quaternion);
1438
+
1439
+ const actual = createMatrix4();
1440
+ rotateMatrix4(actual, actual, axis, radians);
1441
+
1442
+ for (let i = 0; i < 16; i++) {
1443
+ expect(actual.m[i]).toBeCloseTo(expected.m[i], 6);
1444
+ }
1445
+ });
1446
+
1131
1447
  it('preserves translation when rotating locally', () => {
1132
1448
  const m = createMatrix4();
1133
1449
  translateMatrix4(m, m, 5, 0, 0);
@@ -1157,6 +1473,17 @@ describe('rotateMatrix4', () => {
1157
1473
  expect(m.m[5]).toBeCloseTo(1);
1158
1474
  expect(m.m[6]).toBeCloseTo(0);
1159
1475
  });
1476
+ it('writes the same values whether out aliases source or not', () => {
1477
+ const source = createMatrix4();
1478
+ setMatrix4(source, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 7, 8, 9, 1);
1479
+ const distinct = createMatrix4();
1480
+ const aliased = cloneMatrix4(source);
1481
+
1482
+ rotateMatrix4(distinct, source, createVector3(0, 0, 1), Math.PI / 3);
1483
+ rotateMatrix4(aliased, aliased, createVector3(0, 0, 1), Math.PI / 3);
1484
+
1485
+ expect(Array.from(aliased.m)).toEqual(Array.from(distinct.m));
1486
+ });
1160
1487
  });
1161
1488
 
1162
1489
  describe('scaleMatrix4', () => {
@@ -1191,6 +1518,49 @@ describe('scaleMatrix4', () => {
1191
1518
  expect(m.m[13]).toBe(20);
1192
1519
  expect(m.m[14]).toBe(30);
1193
1520
  });
1521
+
1522
+ // A diagonal matrix cannot tell a column scale from a row scale, so a rotated basis with a
1523
+ // non-uniform scale is the only input that pins the multiplication side down.
1524
+ it('post-multiplies by diag(sx, sy, sz, 1) on a rotated basis', () => {
1525
+ const rotated = createMatrix4();
1526
+ setMatrix4(rotated, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 7, 8, 9, 1);
1527
+ const scale = createMatrix4();
1528
+ setMatrix4(scale, 2, 0, 0, 0, 0, 3, 0, 0, 0, 0, 4, 0, 0, 0, 0, 1);
1529
+ const expected = createMatrix4();
1530
+ multiplyMatrix4(expected, rotated, scale);
1531
+
1532
+ const result = createMatrix4();
1533
+ scaleMatrix4(result, rotated, 2, 3, 4);
1534
+
1535
+ expect(Array.from(result.m)).toEqual(Array.from(expected.m));
1536
+ // The discriminating elements: column 0 carries sx, not row 0.
1537
+ expect(result.m[1]).toBe(2);
1538
+ expect(result.m[4]).toBe(-3);
1539
+ });
1540
+
1541
+ it('scales the fourth row of each basis column for a non-affine matrix', () => {
1542
+ const projective = createMatrix4();
1543
+ setMatrix4(projective, 1, 0, 0, 5, 0, 1, 0, 6, 0, 0, 1, 7, 0, 0, 0, 1);
1544
+
1545
+ scaleMatrix4(projective, projective, 2, 3, 4);
1546
+
1547
+ expect(projective.m[3]).toBe(10);
1548
+ expect(projective.m[7]).toBe(18);
1549
+ expect(projective.m[11]).toBe(28);
1550
+ expect(projective.m[15]).toBe(1);
1551
+ });
1552
+
1553
+ it('writes the same values whether out aliases source or not', () => {
1554
+ const source = createMatrix4();
1555
+ setMatrix4(source, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 7, 8, 9, 1);
1556
+ const distinct = createMatrix4();
1557
+ const aliased = cloneMatrix4(source);
1558
+
1559
+ scaleMatrix4(distinct, source, 2, 3, 4);
1560
+ scaleMatrix4(aliased, aliased, 2, 3, 4);
1561
+
1562
+ expect(Array.from(aliased.m)).toEqual(Array.from(distinct.m));
1563
+ });
1194
1564
  });
1195
1565
 
1196
1566
  describe('setMatrix4', () => {
@@ -1439,6 +1809,38 @@ describe('setMatrix4LookAt', () => {
1439
1809
  // Target is 5 units in front of the eye => view-space z = -5 (RH).
1440
1810
  expect(out.z).toBeCloseTo(-5, 6);
1441
1811
  });
1812
+
1813
+ // An overhead camera (up parallel to the view direction) and a camera sitting on its own target
1814
+ // are both reachable from ordinary application code. Roll is arbitrary in those cases, but the
1815
+ // basis must stay orthonormal — a singular view matrix cannot be inverted for picking or for
1816
+ // deriving a camera world transform, and it collapses every rendered position onto a plane.
1817
+ it.each([
1818
+ ['up parallel to the view direction', createVector3(0, 5, 0), createVector3(0, 0, 0), createVector3(0, 1, 0)],
1819
+ ['eye at the target', createVector3(2, 2, 2), createVector3(2, 2, 2), createVector3(0, 1, 0)],
1820
+ ['up parallel to a z-aligned view', createVector3(0, 0, 5), createVector3(0, 0, 0), createVector3(0, 0, 1)],
1821
+ ])('keeps the basis orthonormal when %s', (_case, eye, target, up) => {
1822
+ const m = createMatrix4();
1823
+
1824
+ setMatrix4LookAt(m, eye, target, up);
1825
+
1826
+ for (let i = 0; i < 16; i++) {
1827
+ expect(Number.isFinite(m.m[i])).toBe(true);
1828
+ }
1829
+ const xAxis = createVector3();
1830
+ const yAxis = createVector3();
1831
+ const zAxis = createVector3();
1832
+ setVector3(xAxis, m.m[0], m.m[4], m.m[8]);
1833
+ setVector3(yAxis, m.m[1], m.m[5], m.m[9]);
1834
+ setVector3(zAxis, m.m[2], m.m[6], m.m[10]);
1835
+
1836
+ expect(getVector3Length(xAxis)).toBeCloseTo(1, 6);
1837
+ expect(getVector3Length(yAxis)).toBeCloseTo(1, 6);
1838
+ expect(getVector3Length(zAxis)).toBeCloseTo(1, 6);
1839
+ expect(getVector3Dot(xAxis, yAxis)).toBeCloseTo(0, 6);
1840
+ expect(getVector3Dot(xAxis, zAxis)).toBeCloseTo(0, 6);
1841
+ expect(getVector3Dot(yAxis, zAxis)).toBeCloseTo(0, 6);
1842
+ expect(Math.abs(getMatrix4Determinant(m))).toBeCloseTo(1, 6);
1843
+ });
1442
1844
  });
1443
1845
 
1444
1846
  describe('setMatrix4Position', () => {
@@ -1548,6 +1950,22 @@ describe('translateMatrix4', () => {
1548
1950
  expect(m.m[13]).toBe(7);
1549
1951
  expect(m.m[14]).toBe(9);
1550
1952
  });
1953
+
1954
+ // The distinct-out path copies the basis across before writing the translation. Only the aliased
1955
+ // path was exercised before, so a broken copy would have left the basis at whatever `out` held.
1956
+ it('copies the whole source matrix when out is a distinct object', () => {
1957
+ const source = createMatrix4();
1958
+ setMatrix4(source, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 7, 8, 9, 1);
1959
+ const out = createMatrix4(9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9);
1960
+ const aliased = cloneMatrix4(source);
1961
+
1962
+ translateMatrix4(out, source, 1, 2, 3);
1963
+ translateMatrix4(aliased, aliased, 1, 2, 3);
1964
+
1965
+ expect(Array.from(out.m)).toEqual(Array.from(aliased.m));
1966
+ // The source is left untouched by the distinct-out call.
1967
+ expect(Array.from(source.m)).toEqual([0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 7, 8, 9, 1]);
1968
+ });
1551
1969
  });
1552
1970
 
1553
1971
  describe('transposeMatrix4', () => {