@forgeax/engine-math 0.0.0-dev.8d955ade1c79

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 (150) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +294 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/_arbs.d.ts +36 -0
  5. package/dist/__tests__/_arbs.d.ts.map +1 -0
  6. package/dist/__tests__/_fixtures.d.ts +61 -0
  7. package/dist/__tests__/_fixtures.d.ts.map +1 -0
  8. package/dist/__tests__/bounds2.test.d.ts +2 -0
  9. package/dist/__tests__/bounds2.test.d.ts.map +1 -0
  10. package/dist/__tests__/box3.test.d.ts +2 -0
  11. package/dist/__tests__/box3.test.d.ts.map +1 -0
  12. package/dist/__tests__/easing.test.d.ts +2 -0
  13. package/dist/__tests__/easing.test.d.ts.map +1 -0
  14. package/dist/__tests__/euler.test-d.d.ts +2 -0
  15. package/dist/__tests__/euler.test-d.d.ts.map +1 -0
  16. package/dist/__tests__/mat3.test-d.d.ts +2 -0
  17. package/dist/__tests__/mat3.test-d.d.ts.map +1 -0
  18. package/dist/__tests__/mat4.property.test.d.ts +2 -0
  19. package/dist/__tests__/mat4.property.test.d.ts.map +1 -0
  20. package/dist/__tests__/mat4.test-d.d.ts +2 -0
  21. package/dist/__tests__/mat4.test-d.d.ts.map +1 -0
  22. package/dist/__tests__/mat4.test.d.ts +2 -0
  23. package/dist/__tests__/mat4.test.d.ts.map +1 -0
  24. package/dist/__tests__/noise.test.d.ts +2 -0
  25. package/dist/__tests__/noise.test.d.ts.map +1 -0
  26. package/dist/__tests__/quat.basis.test.d.ts +2 -0
  27. package/dist/__tests__/quat.basis.test.d.ts.map +1 -0
  28. package/dist/__tests__/quat.interpolation.test.d.ts +2 -0
  29. package/dist/__tests__/quat.interpolation.test.d.ts.map +1 -0
  30. package/dist/__tests__/quat.lookat.test.d.ts +2 -0
  31. package/dist/__tests__/quat.lookat.test.d.ts.map +1 -0
  32. package/dist/__tests__/quat.property.test.d.ts +2 -0
  33. package/dist/__tests__/quat.property.test.d.ts.map +1 -0
  34. package/dist/__tests__/quat.rotateaxis.test.d.ts +2 -0
  35. package/dist/__tests__/quat.rotateaxis.test.d.ts.map +1 -0
  36. package/dist/__tests__/quat.test-d.d.ts +2 -0
  37. package/dist/__tests__/quat.test-d.d.ts.map +1 -0
  38. package/dist/__tests__/ray.property.test.d.ts +2 -0
  39. package/dist/__tests__/ray.property.test.d.ts.map +1 -0
  40. package/dist/__tests__/ray.test.d.ts +2 -0
  41. package/dist/__tests__/ray.test.d.ts.map +1 -0
  42. package/dist/__tests__/types.test-d.d.ts +2 -0
  43. package/dist/__tests__/types.test-d.d.ts.map +1 -0
  44. package/dist/__tests__/vec-catmull-rom.test.d.ts +2 -0
  45. package/dist/__tests__/vec-catmull-rom.test.d.ts.map +1 -0
  46. package/dist/__tests__/vec-smooth-damp.test.d.ts +2 -0
  47. package/dist/__tests__/vec-smooth-damp.test.d.ts.map +1 -0
  48. package/dist/__tests__/vec2.test-d.d.ts +2 -0
  49. package/dist/__tests__/vec2.test-d.d.ts.map +1 -0
  50. package/dist/__tests__/vec3.property.test.d.ts +2 -0
  51. package/dist/__tests__/vec3.property.test.d.ts.map +1 -0
  52. package/dist/__tests__/vec3.test-d.d.ts +2 -0
  53. package/dist/__tests__/vec3.test-d.d.ts.map +1 -0
  54. package/dist/__tests__/vec4.test-d.d.ts +2 -0
  55. package/dist/__tests__/vec4.test-d.d.ts.map +1 -0
  56. package/dist/_internal/epsilon.d.ts +9 -0
  57. package/dist/_internal/epsilon.d.ts.map +1 -0
  58. package/dist/_internal/scalar.d.ts +40 -0
  59. package/dist/_internal/scalar.d.ts.map +1 -0
  60. package/dist/box2.d.ts +35 -0
  61. package/dist/box2.d.ts.map +1 -0
  62. package/dist/box3.d.ts +76 -0
  63. package/dist/box3.d.ts.map +1 -0
  64. package/dist/circle2.d.ts +29 -0
  65. package/dist/circle2.d.ts.map +1 -0
  66. package/dist/color.d.ts +64 -0
  67. package/dist/color.d.ts.map +1 -0
  68. package/dist/easing.d.ts +18 -0
  69. package/dist/easing.d.ts.map +1 -0
  70. package/dist/euler.d.ts +51 -0
  71. package/dist/euler.d.ts.map +1 -0
  72. package/dist/f32-to-f16-bytes.d.ts +10 -0
  73. package/dist/f32-to-f16-bytes.d.ts.map +1 -0
  74. package/dist/frustum.d.ts +49 -0
  75. package/dist/frustum.d.ts.map +1 -0
  76. package/dist/index.d.ts +20 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.mjs +3688 -0
  79. package/dist/index.mjs.map +1 -0
  80. package/dist/mat3.d.ts +70 -0
  81. package/dist/mat3.d.ts.map +1 -0
  82. package/dist/mat4.d.ts +329 -0
  83. package/dist/mat4.d.ts.map +1 -0
  84. package/dist/noise.d.ts +12 -0
  85. package/dist/noise.d.ts.map +1 -0
  86. package/dist/quat.d.ts +312 -0
  87. package/dist/quat.d.ts.map +1 -0
  88. package/dist/ray.d.ts +144 -0
  89. package/dist/ray.d.ts.map +1 -0
  90. package/dist/ray2.d.ts +38 -0
  91. package/dist/ray2.d.ts.map +1 -0
  92. package/dist/sphere.d.ts +41 -0
  93. package/dist/sphere.d.ts.map +1 -0
  94. package/dist/types.d.ts +57 -0
  95. package/dist/types.d.ts.map +1 -0
  96. package/dist/vec2.d.ts +76 -0
  97. package/dist/vec2.d.ts.map +1 -0
  98. package/dist/vec3.d.ts +82 -0
  99. package/dist/vec3.d.ts.map +1 -0
  100. package/dist/vec4.d.ts +61 -0
  101. package/dist/vec4.d.ts.map +1 -0
  102. package/package.json +57 -0
  103. package/src/__tests__/_arbs.ts +149 -0
  104. package/src/__tests__/_fixtures.ts +118 -0
  105. package/src/__tests__/bounds2.test.ts +146 -0
  106. package/src/__tests__/box3.test.ts +277 -0
  107. package/src/__tests__/easing.test.ts +109 -0
  108. package/src/__tests__/euler.test-d.ts +63 -0
  109. package/src/__tests__/mat3.test-d.ts +47 -0
  110. package/src/__tests__/mat4.property.test.ts +256 -0
  111. package/src/__tests__/mat4.test-d.ts +130 -0
  112. package/src/__tests__/mat4.test.ts +162 -0
  113. package/src/__tests__/noise.test.ts +75 -0
  114. package/src/__tests__/quat.basis.test.ts +134 -0
  115. package/src/__tests__/quat.interpolation.test.ts +45 -0
  116. package/src/__tests__/quat.lookat.test.ts +103 -0
  117. package/src/__tests__/quat.property.test.ts +150 -0
  118. package/src/__tests__/quat.rotateaxis.test.ts +149 -0
  119. package/src/__tests__/quat.test-d.ts +138 -0
  120. package/src/__tests__/ray.property.test.ts +105 -0
  121. package/src/__tests__/ray.test.ts +539 -0
  122. package/src/__tests__/types.test-d.ts +64 -0
  123. package/src/__tests__/vec-catmull-rom.test.ts +125 -0
  124. package/src/__tests__/vec-smooth-damp.test.ts +167 -0
  125. package/src/__tests__/vec2.test-d.ts +59 -0
  126. package/src/__tests__/vec3.property.test.ts +72 -0
  127. package/src/__tests__/vec3.test-d.ts +65 -0
  128. package/src/__tests__/vec4.test-d.ts +61 -0
  129. package/src/_internal/epsilon.ts +29 -0
  130. package/src/_internal/scalar.ts +90 -0
  131. package/src/box2.ts +168 -0
  132. package/src/box3.ts +372 -0
  133. package/src/circle2.ts +134 -0
  134. package/src/color.ts +177 -0
  135. package/src/easing.ts +49 -0
  136. package/src/euler.ts +239 -0
  137. package/src/f32-to-f16-bytes.ts +71 -0
  138. package/src/frustum.ts +240 -0
  139. package/src/index.ts +65 -0
  140. package/src/mat3.ts +286 -0
  141. package/src/mat4.ts +1334 -0
  142. package/src/noise.ts +78 -0
  143. package/src/quat.ts +847 -0
  144. package/src/ray.ts +575 -0
  145. package/src/ray2.ts +198 -0
  146. package/src/sphere.ts +138 -0
  147. package/src/types.ts +78 -0
  148. package/src/vec2.ts +229 -0
  149. package/src/vec3.ts +294 -0
  150. package/src/vec4.ts +235 -0
@@ -0,0 +1,134 @@
1
+ // quat.basis.test.ts — value tests for quat.right / up / forward (solo round 20260713-174912)
2
+ //
3
+ // Regression guard for the friction that motivated the accessors: moving an entity "along where
4
+ // it faces" (Bevy `transform.local_x()` / `.forward()`) required hand-wiring
5
+ // `quat.transformVec3(out, q, [0, 0, -1])` AND independently knowing the −Z forward handedness
6
+ // (documented only inside mat4.ts). A demo author who guessed +Z, or forgot to pick the right
7
+ // canonical axis, got a silently-wrong direction. These tests pin:
8
+ // 1. identity rotation → the canonical world axes (right=+X, up=+Y, forward=−Z),
9
+ // 2. a known rotation maps the axes as expected (90° yaw about +Y),
10
+ // 3. the three accessors equal q applied to the canonical axes (transformVec3 SSOT),
11
+ // 4. they agree with the mat4 basis getters on the same rotation (cross-impl witness —
12
+ // the −Z forward convention is shared, not re-derived),
13
+ // 5. a fromLookAt camera's forward ≈ normalize(target − eye) (the look-convention anchor),
14
+ // 6. results stay unit-length (a unit q in → a unit vector out),
15
+ // 7. aliasing / degrade (zero quat → the natural transformVec3 result, no NaN),
16
+ // 8. right/up/forward stay mutually orthogonal (a valid orthonormal basis).
17
+
18
+ import { describe, expect, it } from 'vitest';
19
+ import * as mat4 from '../mat4';
20
+ import * as quat from '../quat';
21
+ import type { QuatLike, Vec3Like } from '../types';
22
+ import * as vec3 from '../vec3';
23
+
24
+ const EPS = 1e-5;
25
+
26
+ function expectVec3Close(actual: Vec3Like, expected: Vec3Like, eps = EPS): void {
27
+ expect(actual[0]).toBeCloseTo(expected[0] as number, 5);
28
+ expect(actual[1]).toBeCloseTo(expected[1] as number, 5);
29
+ expect(actual[2]).toBeCloseTo(expected[2] as number, 5);
30
+ void eps;
31
+ }
32
+
33
+ function dot(a: Vec3Like, b: Vec3Like): number {
34
+ return (
35
+ (a[0] as number) * (b[0] as number) +
36
+ (a[1] as number) * (b[1] as number) +
37
+ (a[2] as number) * (b[2] as number)
38
+ );
39
+ }
40
+
41
+ describe('quat.right / up / forward — local basis accessors', () => {
42
+ it('identity rotation → canonical world axes (right=+X, up=+Y, forward=−Z)', () => {
43
+ const id = quat.identity(quat.create());
44
+ expectVec3Close(quat.right(vec3.create(), id), [1, 0, 0]);
45
+ expectVec3Close(quat.up(vec3.create(), id), [0, 1, 0]);
46
+ expectVec3Close(quat.forward(vec3.create(), id), [0, 0, -1]);
47
+ });
48
+
49
+ it('90° yaw about +Y maps the axes as expected', () => {
50
+ // A left-hand-rule +Y yaw of 90°: +X → −Z, −Z → −X, +Y stays +Y.
51
+ const q = quat.eulerY(Math.PI / 2);
52
+ expectVec3Close(quat.right(vec3.create(), q), [0, 0, -1]);
53
+ expectVec3Close(quat.up(vec3.create(), q), [0, 1, 0]);
54
+ expectVec3Close(quat.forward(vec3.create(), q), [-1, 0, 0]);
55
+ });
56
+
57
+ it('each accessor equals q applied to its canonical axis (transformVec3 SSOT)', () => {
58
+ const q = quat.fromEuler(quat.create(), 0.4, -0.7, 1.1, 'XYZ');
59
+ expectVec3Close(quat.right(vec3.create(), q), quat.transformVec3(vec3.create(), q, [1, 0, 0]));
60
+ expectVec3Close(quat.up(vec3.create(), q), quat.transformVec3(vec3.create(), q, [0, 1, 0]));
61
+ expectVec3Close(
62
+ quat.forward(vec3.create(), q),
63
+ quat.transformVec3(vec3.create(), q, [0, 0, -1]),
64
+ );
65
+ });
66
+
67
+ it('WITNESS: agrees with mat4.getRight / getUp / getForward on the same rotation', () => {
68
+ // The −Z forward convention is shared between quat and mat4 — an independent implementation
69
+ // (mat4 reads basis columns) must land the same directions. If either side flips a sign this
70
+ // fails, catching a convention drift the single-namespace tests cannot.
71
+ const q = quat.fromEuler(quat.create(), 0.9, 0.3, -0.5, 'YXZ');
72
+ const m = mat4.fromQuat(mat4.create(), q);
73
+ expectVec3Close(quat.right(vec3.create(), q), mat4.getRight(vec3.create(), m));
74
+ expectVec3Close(quat.up(vec3.create(), q), mat4.getUp(vec3.create(), m));
75
+ expectVec3Close(quat.forward(vec3.create(), q), mat4.getForward(vec3.create(), m));
76
+ });
77
+
78
+ it('fromLookAt camera → forward ≈ normalize(target − eye)', () => {
79
+ // The look-convention anchor: the whole point of the −Z choice is that a camera built with
80
+ // fromLookAt faces its target. This is the property a demo relies on.
81
+ const eye: Vec3Like = [5, 3, -2];
82
+ const target: Vec3Like = [-1, 0, 4];
83
+ const q = quat.fromLookAt(quat.create(), eye, target, [0, 1, 0]);
84
+ const expected = vec3.normalize(
85
+ vec3.create(),
86
+ vec3.create(
87
+ (target[0] as number) - (eye[0] as number),
88
+ (target[1] as number) - (eye[1] as number),
89
+ (target[2] as number) - (eye[2] as number),
90
+ ),
91
+ );
92
+ expectVec3Close(quat.forward(vec3.create(), q), expected);
93
+ });
94
+
95
+ it('a unit quaternion yields unit-length basis vectors', () => {
96
+ const q = quat.fromAxisAngle(quat.create(), [0.3, 0.8, -0.5], 2.1);
97
+ expect(vec3.length(quat.right(vec3.create(), q))).toBeCloseTo(1, 5);
98
+ expect(vec3.length(quat.up(vec3.create(), q))).toBeCloseTo(1, 5);
99
+ expect(vec3.length(quat.forward(vec3.create(), q))).toBeCloseTo(1, 5);
100
+ });
101
+
102
+ it('right / up / forward form an orthonormal (mutually perpendicular) basis', () => {
103
+ const q = quat.fromEuler(quat.create(), 1.2, -0.4, 0.6, 'ZYX');
104
+ const r = quat.right(vec3.create(), q);
105
+ const u = quat.up(vec3.create(), q);
106
+ const f = quat.forward(vec3.create(), q);
107
+ expect(dot(r, u)).toBeCloseTo(0, 5);
108
+ expect(dot(r, f)).toBeCloseTo(0, 5);
109
+ expect(dot(u, f)).toBeCloseTo(0, 5);
110
+ });
111
+
112
+ it('degrade: zero quaternion → natural transformVec3 result, no NaN', () => {
113
+ const zero: QuatLike = new Float32Array([0, 0, 0, 0]);
114
+ const r = quat.right(vec3.create(), zero);
115
+ const u = quat.up(vec3.create(), zero);
116
+ const f = quat.forward(vec3.create(), zero);
117
+ // transformVec3 with q=(0,0,0,0) → out = v (the input axis), non-NaN.
118
+ expectVec3Close(r, [1, 0, 0]);
119
+ expectVec3Close(u, [0, 1, 0]);
120
+ expectVec3Close(f, [0, 0, -1]);
121
+ for (const v of [r, u, f]) {
122
+ expect(Number.isNaN(v[0])).toBe(false);
123
+ expect(Number.isNaN(v[1])).toBe(false);
124
+ expect(Number.isNaN(v[2])).toBe(false);
125
+ }
126
+ });
127
+
128
+ it('aliasing-safe: out and a fresh vec3 give the same result', () => {
129
+ const q = quat.eulerY(Math.PI / 3);
130
+ const a = quat.forward(vec3.create(), q);
131
+ const b = quat.forward(vec3.create(), q);
132
+ expectVec3Close(a, b);
133
+ });
134
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { quat } from '../index';
3
+
4
+ function expectQuatClose(actual: ArrayLike<number>, expected: ArrayLike<number>): void {
5
+ for (let i = 0; i < 4; i++) {
6
+ expect(actual[i]).toBeCloseTo(expected[i] as number, 6);
7
+ }
8
+ }
9
+
10
+ describe('quat.slerp endpoint fallback', () => {
11
+ it('matches nlerp for coincident and anti-parallel inputs', () => {
12
+ const a = quat.fromAxisAngle(quat.create(), [0, 1, 0], 0.3);
13
+ const near = quat.fromAxisAngle(quat.create(), [0, 1, 0], 0.3000001);
14
+ const opposite = new Float32Array([
15
+ -(a[0] as number),
16
+ -(a[1] as number),
17
+ -(a[2] as number),
18
+ -(a[3] as number),
19
+ ]);
20
+ const slerpNear = quat.slerp(quat.create(), a, near, 0.25);
21
+ const nlerpNear = quat.nlerp(quat.create(), a, near, 0.25);
22
+ const slerpOpposite = quat.slerp(quat.create(), a, opposite, 0.25);
23
+ const nlerpOpposite = quat.nlerp(quat.create(), a, opposite, 0.25);
24
+
25
+ expectQuatClose(slerpNear, nlerpNear);
26
+ expectQuatClose(slerpOpposite, nlerpOpposite);
27
+ });
28
+
29
+ it('preserves aliasing-safe endpoint behavior', () => {
30
+ const a = quat.fromAxisAngle(quat.create(), [1, 0, 0], 0.4);
31
+ const b = quat.fromAxisAngle(quat.create(), [1, 0, 0], 0.4000001);
32
+ const expected = quat.slerp(quat.create(), a, b, 0.5);
33
+ const inPlace = quat.clone(a);
34
+
35
+ expect(quat.slerp(inPlace, inPlace, b, 0.5)).toBe(inPlace);
36
+ expectQuatClose(inPlace, expected);
37
+ });
38
+
39
+ it('keeps the zero-result fallback owned by nlerp', () => {
40
+ const b = Float32Array.of(1.000001, 0, 0, 0);
41
+ const result = quat.slerp(quat.create(), [1, 0, 0, 0], b, -1_048_576);
42
+
43
+ expect(result).toEqual(quat.identity(quat.create()));
44
+ });
45
+ });
@@ -0,0 +1,103 @@
1
+ // quat.lookat.test.ts — value tests for quat.fromLookAt (solo round 20260713-141636)
2
+ //
3
+ // Regression guard for the friction that motivated the helper: aiming a camera required
4
+ // hand-wiring `mat4.lookAt → invert → mat3.fromMat4 → quat.fromRotationMatrix`, and because
5
+ // `Mat4Like` ≡ `Mat3Like` ≡ ArrayLike<number>, feeding the mat4 straight into fromRotationMatrix
6
+ // typechecks but reads garbage columns → NaN → nothing renders. These tests pin:
7
+ // 1. fromLookAt matches the correct hand-wired chain,
8
+ // 2. the resulting rotation actually points local -z at the target,
9
+ // 3. the buggy mat4-into-fromRotationMatrix path yields a DIFFERENT (wrong) quaternion,
10
+ // 4. the documented degrade cases (eye≈target, up‖forward).
11
+
12
+ import { describe, expect, it } from 'vitest';
13
+ import * as mat3 from '../mat3';
14
+ import * as mat4 from '../mat4';
15
+ import * as quat from '../quat';
16
+ import type { Vec3Like } from '../types';
17
+ import * as vec3 from '../vec3';
18
+
19
+ const EPS = 1e-5;
20
+
21
+ /** The correct hand-wired chain the helper replaces (round-3 apps/hello/3d-scene lookAtQuat). */
22
+ function handWired(eye: Vec3Like, target: Vec3Like, up: Vec3Like): Float32Array {
23
+ const view = mat4.lookAt(mat4.create(), eye, target, up);
24
+ const camWorld = mat4.invert(mat4.create(), view);
25
+ const rot3 = mat3.fromMat4(mat3.create(), camWorld);
26
+ return quat.normalize(quat.create(), quat.fromRotationMatrix(quat.create(), rot3));
27
+ }
28
+
29
+ /** quat double-cover: q and -q are the same rotation. Compare via |dot| ≈ 1. */
30
+ function sameRotation(a: Float32Array, b: Float32Array): boolean {
31
+ const d =
32
+ (a[0] as number) * (b[0] as number) +
33
+ (a[1] as number) * (b[1] as number) +
34
+ (a[2] as number) * (b[2] as number) +
35
+ (a[3] as number) * (b[3] as number);
36
+ return Math.abs(Math.abs(d) - 1) < EPS;
37
+ }
38
+
39
+ describe('quat.fromLookAt', () => {
40
+ const cases: Array<{ name: string; eye: Vec3Like; target: Vec3Like; up: Vec3Like }> = [
41
+ { name: '3d_scene camera', eye: [-2.5, 4.5, 9], target: [0, 0, 0], up: [0, 1, 0] },
42
+ { name: 'axis-aligned +x view', eye: [5, 0, 0], target: [0, 0, 0], up: [0, 1, 0] },
43
+ { name: 'above looking down', eye: [0, 10, 0.001], target: [0, 0, 0], up: [0, 1, 0] },
44
+ { name: 'off-axis', eye: [3, 2, -4], target: [1, 0, 1], up: [0, 1, 0] },
45
+ ];
46
+
47
+ for (const c of cases) {
48
+ it(`matches the hand-wired lookAt→invert→mat3→fromRotationMatrix chain (${c.name})`, () => {
49
+ const helper = quat.fromLookAt(quat.create(), c.eye, c.target, c.up);
50
+ const chain = handWired(c.eye, c.target, c.up);
51
+ expect(sameRotation(helper, chain)).toBe(true);
52
+ });
53
+
54
+ it(`rotates local -z to point from eye toward target (${c.name})`, () => {
55
+ const q = quat.fromLookAt(quat.create(), c.eye, c.target, c.up);
56
+ // world direction the camera faces = rotation applied to local forward (-z)
57
+ const facing = quat.transformVec3(vec3.create(), q, [0, 0, -1]);
58
+ const want = vec3.normalize(
59
+ vec3.create(),
60
+ vec3.sub(
61
+ vec3.create(),
62
+ vec3.create(c.target[0] as number, c.target[1] as number, c.target[2] as number),
63
+ vec3.create(c.eye[0] as number, c.eye[1] as number, c.eye[2] as number),
64
+ ),
65
+ );
66
+ expect(facing[0]).toBeCloseTo(want[0] as number, 4);
67
+ expect(facing[1]).toBeCloseTo(want[1] as number, 4);
68
+ expect(facing[2]).toBeCloseTo(want[2] as number, 4);
69
+ });
70
+ }
71
+
72
+ it('produces no NaN components (the friction was NaN→black)', () => {
73
+ const q = quat.fromLookAt(quat.create(), [-2.5, 4.5, 9], [0, 0, 0], [0, 1, 0]);
74
+ for (let i = 0; i < 4; i++) expect(Number.isNaN(q[i] as number)).toBe(false);
75
+ });
76
+
77
+ it('WITNESS: feeding a mat4 to fromRotationMatrix (the trap) gives a DIFFERENT, wrong result', () => {
78
+ // This is the exact footgun fromLookAt exists to remove. Mat4Like≡Mat3Like≡ArrayLike<number>,
79
+ // so this typechecks; it reads mat4 elements 0..8 (garbage columns) instead of a real mat3.
80
+ const eye: Vec3Like = [-2.5, 4.5, 9];
81
+ const target: Vec3Like = [0, 0, 0];
82
+ const up: Vec3Like = [0, 1, 0];
83
+ const view = mat4.lookAt(mat4.create(), eye, target, up);
84
+ const camWorld = mat4.invert(mat4.create(), view);
85
+ // BUG path: pass the 16-element mat4 where a 9-element mat3 is expected.
86
+ const trap = quat.normalize(quat.create(), quat.fromRotationMatrix(quat.create(), camWorld));
87
+ const correct = quat.fromLookAt(quat.create(), eye, target, up);
88
+ expect(sameRotation(trap, correct)).toBe(false);
89
+ });
90
+
91
+ it('degrade: eye ≈ target → identity (no throw)', () => {
92
+ const q = quat.fromLookAt(quat.create(), [1, 1, 1], [1, 1, 1], [0, 1, 0]);
93
+ expect(sameRotation(q, quat.identity(quat.create()))).toBe(true);
94
+ });
95
+
96
+ it('degrade: up collinear with forward → still a valid (non-NaN) unit quaternion', () => {
97
+ // looking straight down with up=(0,1,0): up ‖ forward, alternative-up path engages.
98
+ const q = quat.fromLookAt(quat.create(), [0, 5, 0], [0, 0, 0], [0, 1, 0]);
99
+ for (let i = 0; i < 4; i++) expect(Number.isNaN(q[i] as number)).toBe(false);
100
+ const len = Math.hypot(q[0] as number, q[1] as number, q[2] as number, q[3] as number);
101
+ expect(len).toBeCloseTo(1, 4);
102
+ });
103
+ });
@@ -0,0 +1,150 @@
1
+ // quat.property.test.ts — fast-check property tests (M5 / T-031, AC-11)
2
+ //
3
+ // Category coverage (5 cases / 4 categories):
4
+ // - identity ×1: q·1 = q (quat multiplicative identity)
5
+ // - invertibility ×1: q·conjugate(q) ≈ identity (for unit quaternions, conjugate equals inverse)
6
+ // - normalize idempotence ×1: |normalize(q)| ≈ 1
7
+ // - slerp endpoints ×2: slerp(a,b,0) ≈ a, slerp(a,b,1) ≈ b
8
+ //
9
+ // quat equivalence: q ≡ -q (same rotation), so endpoint comparison uses |dot(a,b)| ≈ 1 (wiki §4.3).
10
+ //
11
+ // Related: requirements §AC-11; plan-strategy D-P5 / D-P6; wiki/fast-check-property-test §5.5.
12
+
13
+ import { fc, test } from '@fast-check/vitest';
14
+ import { describe, expect } from 'vitest';
15
+ import * as quat from '../quat';
16
+ import type { Vec3 } from '../types';
17
+ import { EPS_F32, EPS_QUAT, unitQuatArb, unitVec3Arb, vec3Arb, vec4Arb } from './_arbs';
18
+
19
+ // process.env is read via globalThis to avoid pulling in the @types/node devDep (AC-16 / D-P13).
20
+ const NUM_RUNS = Number.parseInt(
21
+ (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env
22
+ ?.PROPERTY_NUM_RUNS ?? '100',
23
+ 10,
24
+ );
25
+
26
+ /** quat equivalence comparison: |dot(a,b)| ≈ 1 (double cover q ≡ -q). */
27
+ function quatEquivalent(a: Float32Array, b: Float32Array, eps: number): boolean {
28
+ const d =
29
+ (a[0] as number) * (b[0] as number) +
30
+ (a[1] as number) * (b[1] as number) +
31
+ (a[2] as number) * (b[2] as number) +
32
+ (a[3] as number) * (b[3] as number);
33
+ return Math.abs(Math.abs(d) - 1) < eps;
34
+ }
35
+
36
+ describe('quat property: identity', () => {
37
+ test.prop({ q: unitQuatArb() }, { numRuns: NUM_RUNS })('q · identity ≈ q', ({ q }) => {
38
+ const I = quat.create();
39
+ quat.identity(I);
40
+ const out = quat.create();
41
+ quat.multiply(out, q, I);
42
+ return quatEquivalent(out, q, EPS_QUAT);
43
+ });
44
+ });
45
+
46
+ describe('quat property: invertibility', () => {
47
+ test.prop({ q: unitQuatArb() }, { numRuns: NUM_RUNS })(
48
+ 'q · conjugate(q) ≈ identity (unit quaternion)',
49
+ ({ q }) => {
50
+ const c = quat.create();
51
+ quat.conjugate(c, q);
52
+ const out = quat.create();
53
+ quat.multiply(out, q, c);
54
+ const expected = quat.create();
55
+ quat.identity(expected);
56
+ return quatEquivalent(out, expected, EPS_QUAT);
57
+ },
58
+ );
59
+ });
60
+
61
+ describe('quat property: normalize idempotence', () => {
62
+ test.prop({ raw: vec4Arb() }, { numRuns: NUM_RUNS })(
63
+ '|normalize(q)| ≈ 1 (any non-zero quaternion)',
64
+ ({ raw }) => {
65
+ // Reuse vec4Arb as un-normalized quat material; the zero vector is mapper-repaired to identity
66
+ const x = raw[0] as number;
67
+ const y = raw[1] as number;
68
+ const z = raw[2] as number;
69
+ const w = raw[3] as number;
70
+ const len2 = x * x + y * y + z * z + w * w;
71
+ const src =
72
+ len2 < 1e-12
73
+ ? quat.identity(quat.create())
74
+ : (Float32Array.of(x, y, z, w) as unknown as ReturnType<typeof quat.create>);
75
+ const out = quat.create();
76
+ quat.normalize(out, src);
77
+ const len = Math.hypot(
78
+ out[0] as number,
79
+ out[1] as number,
80
+ out[2] as number,
81
+ out[3] as number,
82
+ );
83
+ return Math.abs(len - 1) < EPS_F32;
84
+ },
85
+ );
86
+ });
87
+
88
+ describe('quat property: slerp endpoints', () => {
89
+ test.prop({ a: unitQuatArb(), b: unitQuatArb() }, { numRuns: NUM_RUNS })(
90
+ 'slerp(a, b, 0) ≈ a',
91
+ ({ a, b }) => {
92
+ const out = quat.create();
93
+ quat.slerp(out, a, b, 0);
94
+ return quatEquivalent(out, a, EPS_QUAT);
95
+ },
96
+ );
97
+
98
+ test.prop({ a: unitQuatArb(), b: unitQuatArb() }, { numRuns: NUM_RUNS })(
99
+ 'slerp(a, b, 1) ≈ b',
100
+ ({ a, b }) => {
101
+ const out = quat.create();
102
+ quat.slerp(out, a, b, 1);
103
+ return quatEquivalent(out, b, EPS_QUAT);
104
+ },
105
+ );
106
+ });
107
+
108
+ // M1 / t5 — quat.transformVec3 property +2
109
+ //
110
+ // 2 cases:
111
+ // - length preservation |q*v*q⁻¹| ≈ |v| (restricted to unitQuatArb, keeps q unit-length premise)
112
+ // - identity unit transformVec3(quat.identity(), v) ≈ v
113
+ //
114
+ // numRuns: 100 default; EPS 1e-5; use the unitQuatArb factory to avoid random q triggering
115
+ // implicit-scale degeneracy (R-2 countermeasure).
116
+ //
117
+ // Related: requirements §3.3 property +8 (quat contributes 2 cases) + §6 AC-11;
118
+ // research Finding 5 row 4 + Finding 3 (unit q length preservation 100%);
119
+ // plan-strategy §3 R-2 + §4.3 key test points table row 4.
120
+
121
+ describe('quat.transformVec3 property (t5)', () => {
122
+ test.prop({ q: unitQuatArb(), v: vec3Arb() }, { numRuns: NUM_RUNS })(
123
+ 'length preservation: |q · v · q⁻¹| ≈ |v| (q unit-length premise)',
124
+ ({ q, v }) => {
125
+ const out = new Float32Array(3) as Vec3;
126
+ quat.transformVec3(out, q, v);
127
+ const lenIn = Math.hypot(v[0] as number, v[1] as number, v[2] as number);
128
+ const lenOut = Math.hypot(out[0] as number, out[1] as number, out[2] as number);
129
+ // relative error: scale = max(1, lenIn)
130
+ const scale = Math.max(1, lenIn);
131
+ return Math.abs(lenOut - lenIn) < EPS_F32 * scale;
132
+ },
133
+ );
134
+
135
+ test.prop({ v: unitVec3Arb() }, { numRuns: NUM_RUNS })(
136
+ 'identity unit: transformVec3(identity, v) ≈ v',
137
+ ({ v }) => {
138
+ const I = quat.identity(quat.create());
139
+ const out = new Float32Array(3) as Vec3;
140
+ quat.transformVec3(out, I, v);
141
+ const dx = (out[0] as number) - (v[0] as number);
142
+ const dy = (out[1] as number) - (v[1] as number);
143
+ const dz = (out[2] as number) - (v[2] as number);
144
+ return Math.abs(dx) < EPS_F32 && Math.abs(dy) < EPS_F32 && Math.abs(dz) < EPS_F32;
145
+ },
146
+ );
147
+ });
148
+
149
+ void fc;
150
+ void expect;
@@ -0,0 +1,149 @@
1
+ // quat.rotateaxis.test.ts — value tests for quat.rotateAxis (solo round 20260713-164916)
2
+ //
3
+ // Regression guard for the friction that motivated the helper: incrementally rotating an entity
4
+ // each frame required hand-wiring `multiply(q, fromAxisAngle(axis, θ), q)`, and because that loop
5
+ // never re-normalizes, floating-point error accumulates over thousands of frames into a non-unit
6
+ // quaternion (skew / scale artefacts). Demos worked around it (raw sin/cos literals, or an
7
+ // absolute-angle accumulator that can't compose onto an existing orientation). These tests pin:
8
+ // 1. rotateAxis matches the correct hand-wired normalize(multiply(delta, q)) chain,
9
+ // 2. the delta is PRE-multiplied (world-space axis, matching Bevy Transform::rotate order),
10
+ // 3. a full 2π rotation returns to the start orientation,
11
+ // 4. aliasing rotateAxis(q, q, ...) is correct (the common in-place per-frame call),
12
+ // 5. WITNESS: the naive un-normalized multiply loop drifts off unit length; rotateAxis does not,
13
+ // 6. the documented degrade case (zero-length axis → normalize(q), no rotation).
14
+
15
+ import { describe, expect, it } from 'vitest';
16
+ import * as quat from '../quat';
17
+ import type { QuatLike, Vec3Like } from '../types';
18
+
19
+ const EPS = 1e-5;
20
+
21
+ /** quat double-cover: q and -q are the same rotation. Compare via |dot| ≈ 1. */
22
+ function sameRotation(a: QuatLike, b: QuatLike): boolean {
23
+ const d =
24
+ (a[0] as number) * (b[0] as number) +
25
+ (a[1] as number) * (b[1] as number) +
26
+ (a[2] as number) * (b[2] as number) +
27
+ (a[3] as number) * (b[3] as number);
28
+ return Math.abs(Math.abs(d) - 1) < EPS;
29
+ }
30
+
31
+ /** The correct hand-wired chain the helper replaces: normalize(delta * q). */
32
+ function handWired(q: QuatLike, axis: Vec3Like, angle: number): Float32Array {
33
+ const delta = quat.fromAxisAngle(quat.create(), axis, angle);
34
+ return quat.normalize(quat.create(), quat.multiply(quat.create(), delta, q));
35
+ }
36
+
37
+ const Y: Vec3Like = [0, 1, 0];
38
+
39
+ describe('quat.rotateAxis', () => {
40
+ const cases: Array<{ name: string; start: () => Float32Array; axis: Vec3Like; angle: number }> = [
41
+ {
42
+ name: 'identity + Y π/2',
43
+ start: () => quat.identity(quat.create()),
44
+ axis: Y,
45
+ angle: Math.PI / 2,
46
+ },
47
+ {
48
+ name: 'identity + X π/3',
49
+ start: () => quat.identity(quat.create()),
50
+ axis: [1, 0, 0],
51
+ angle: Math.PI / 3,
52
+ },
53
+ {
54
+ name: 'non-identity start + Z π/4',
55
+ start: () => quat.fromAxisAngle(quat.create(), Y, 0.7),
56
+ axis: [0, 0, 1],
57
+ angle: Math.PI / 4,
58
+ },
59
+ {
60
+ name: 'unnormalized axis (2Y) π/2',
61
+ start: () => quat.identity(quat.create()),
62
+ axis: [0, 2, 0],
63
+ angle: Math.PI / 2,
64
+ },
65
+ ];
66
+
67
+ for (const c of cases) {
68
+ it(`matches the hand-wired normalize(delta * q) chain (${c.name})`, () => {
69
+ const helper = quat.rotateAxis(quat.create(), c.start(), c.axis, c.angle);
70
+ const chain = handWired(c.start(), c.axis, c.angle);
71
+ expect(sameRotation(helper, chain)).toBe(true);
72
+ });
73
+
74
+ it(`stays unit length (${c.name})`, () => {
75
+ const q = quat.rotateAxis(quat.create(), c.start(), c.axis, c.angle);
76
+ expect(quat.length(q)).toBeCloseTo(1, 5);
77
+ });
78
+ }
79
+
80
+ it('PRE-multiplies the delta (world-space axis, Bevy Transform::rotate order): delta*q ≠ q*delta', () => {
81
+ // Start with a rotation about X, then rotate about Y. Order matters for non-commuting axes.
82
+ const start = quat.fromAxisAngle(quat.create(), [1, 0, 0], Math.PI / 2);
83
+ const helper = quat.rotateAxis(quat.create(), start, Y, Math.PI / 2);
84
+ const pre = quat.normalize(
85
+ quat.create(),
86
+ quat.multiply(quat.create(), quat.fromAxisAngle(quat.create(), Y, Math.PI / 2), start),
87
+ );
88
+ const post = quat.normalize(
89
+ quat.create(),
90
+ quat.multiply(quat.create(), start, quat.fromAxisAngle(quat.create(), Y, Math.PI / 2)),
91
+ );
92
+ expect(sameRotation(helper, pre)).toBe(true);
93
+ // The two orders genuinely differ for these axes — pins that we chose pre, not post.
94
+ expect(sameRotation(pre, post)).toBe(false);
95
+ });
96
+
97
+ it('a full 2π rotation returns to the starting orientation', () => {
98
+ const start = quat.fromAxisAngle(quat.create(), [1, 0, 0], 0.9);
99
+ const out = quat.rotateAxis(quat.create(), start, Y, Math.PI * 2);
100
+ expect(sameRotation(out, start)).toBe(true);
101
+ });
102
+
103
+ it('is aliasing-safe: rotateAxis(q, q, ...) matches the non-aliased result', () => {
104
+ const a = quat.fromAxisAngle(quat.create(), Y, 0.3);
105
+ const b = quat.clone(a);
106
+ const aliased = quat.rotateAxis(a, a, [1, 0, 0], 0.5);
107
+ const separate = quat.rotateAxis(quat.create(), b, [1, 0, 0], 0.5);
108
+ expect(sameRotation(aliased, separate)).toBe(true);
109
+ expect(quat.length(aliased)).toBeCloseTo(1, 5);
110
+ });
111
+
112
+ it('WITNESS: naive un-normalized multiply loop drifts off unit length; rotateAxis does not', () => {
113
+ const N = 10000;
114
+ const step = 0.017; // small per-"frame" angle
115
+ // Control: the buggy hand-rolled loop demos worked around — multiply only, never normalize.
116
+ const naive = quat.identity(quat.create());
117
+ for (let i = 0; i < N; i++) {
118
+ const delta = quat.fromAxisAngle(quat.create(), Y, step);
119
+ quat.multiply(naive, delta, naive);
120
+ }
121
+ const naiveErr = Math.abs(quat.length(naive) - 1);
122
+
123
+ // rotateAxis: identical math + the normalize the naive loop omits.
124
+ const safe = quat.identity(quat.create());
125
+ for (let i = 0; i < N; i++) {
126
+ quat.rotateAxis(safe, safe, Y, step);
127
+ }
128
+ const safeErr = Math.abs(quat.length(safe) - 1);
129
+
130
+ // The naive loop's unit-length error accumulates; rotateAxis re-normalizes every step so its
131
+ // error stays at machine epsilon. The friction is real (naive drifts) and the helper removes it:
132
+ // rotateAxis is strictly tighter to unit length, by orders of magnitude.
133
+ expect(naiveErr).toBeGreaterThan(1e-6); // measurable drift in the un-normalized loop
134
+ expect(safeErr).toBeLessThan(1e-6); // rotateAxis holds unit length
135
+ expect(safeErr).toBeLessThan(naiveErr); // the helper is strictly better
136
+ });
137
+
138
+ it('degrade: zero-length axis → out = normalize(q), no rotation applied (registry #8)', () => {
139
+ const start = quat.fromAxisAngle(quat.create(), Y, 0.6);
140
+ const out = quat.rotateAxis(quat.create(), start, [0, 0, 0], 1.23);
141
+ expect(sameRotation(out, start)).toBe(true);
142
+ expect(quat.length(out)).toBeCloseTo(1, 5);
143
+ });
144
+
145
+ it('produces no NaN components', () => {
146
+ const out = quat.rotateAxis(quat.create(), quat.identity(quat.create()), Y, 0.5);
147
+ for (let i = 0; i < 4; i++) expect(Number.isNaN(out[i] as number)).toBe(false);
148
+ });
149
+ });