@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.
- package/LICENSE +202 -0
- package/README.md +294 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/_arbs.d.ts +36 -0
- package/dist/__tests__/_arbs.d.ts.map +1 -0
- package/dist/__tests__/_fixtures.d.ts +61 -0
- package/dist/__tests__/_fixtures.d.ts.map +1 -0
- package/dist/__tests__/bounds2.test.d.ts +2 -0
- package/dist/__tests__/bounds2.test.d.ts.map +1 -0
- package/dist/__tests__/box3.test.d.ts +2 -0
- package/dist/__tests__/box3.test.d.ts.map +1 -0
- package/dist/__tests__/easing.test.d.ts +2 -0
- package/dist/__tests__/easing.test.d.ts.map +1 -0
- package/dist/__tests__/euler.test-d.d.ts +2 -0
- package/dist/__tests__/euler.test-d.d.ts.map +1 -0
- package/dist/__tests__/mat3.test-d.d.ts +2 -0
- package/dist/__tests__/mat3.test-d.d.ts.map +1 -0
- package/dist/__tests__/mat4.property.test.d.ts +2 -0
- package/dist/__tests__/mat4.property.test.d.ts.map +1 -0
- package/dist/__tests__/mat4.test-d.d.ts +2 -0
- package/dist/__tests__/mat4.test-d.d.ts.map +1 -0
- package/dist/__tests__/mat4.test.d.ts +2 -0
- package/dist/__tests__/mat4.test.d.ts.map +1 -0
- package/dist/__tests__/noise.test.d.ts +2 -0
- package/dist/__tests__/noise.test.d.ts.map +1 -0
- package/dist/__tests__/quat.basis.test.d.ts +2 -0
- package/dist/__tests__/quat.basis.test.d.ts.map +1 -0
- package/dist/__tests__/quat.interpolation.test.d.ts +2 -0
- package/dist/__tests__/quat.interpolation.test.d.ts.map +1 -0
- package/dist/__tests__/quat.lookat.test.d.ts +2 -0
- package/dist/__tests__/quat.lookat.test.d.ts.map +1 -0
- package/dist/__tests__/quat.property.test.d.ts +2 -0
- package/dist/__tests__/quat.property.test.d.ts.map +1 -0
- package/dist/__tests__/quat.rotateaxis.test.d.ts +2 -0
- package/dist/__tests__/quat.rotateaxis.test.d.ts.map +1 -0
- package/dist/__tests__/quat.test-d.d.ts +2 -0
- package/dist/__tests__/quat.test-d.d.ts.map +1 -0
- package/dist/__tests__/ray.property.test.d.ts +2 -0
- package/dist/__tests__/ray.property.test.d.ts.map +1 -0
- package/dist/__tests__/ray.test.d.ts +2 -0
- package/dist/__tests__/ray.test.d.ts.map +1 -0
- package/dist/__tests__/types.test-d.d.ts +2 -0
- package/dist/__tests__/types.test-d.d.ts.map +1 -0
- package/dist/__tests__/vec-catmull-rom.test.d.ts +2 -0
- package/dist/__tests__/vec-catmull-rom.test.d.ts.map +1 -0
- package/dist/__tests__/vec-smooth-damp.test.d.ts +2 -0
- package/dist/__tests__/vec-smooth-damp.test.d.ts.map +1 -0
- package/dist/__tests__/vec2.test-d.d.ts +2 -0
- package/dist/__tests__/vec2.test-d.d.ts.map +1 -0
- package/dist/__tests__/vec3.property.test.d.ts +2 -0
- package/dist/__tests__/vec3.property.test.d.ts.map +1 -0
- package/dist/__tests__/vec3.test-d.d.ts +2 -0
- package/dist/__tests__/vec3.test-d.d.ts.map +1 -0
- package/dist/__tests__/vec4.test-d.d.ts +2 -0
- package/dist/__tests__/vec4.test-d.d.ts.map +1 -0
- package/dist/_internal/epsilon.d.ts +9 -0
- package/dist/_internal/epsilon.d.ts.map +1 -0
- package/dist/_internal/scalar.d.ts +40 -0
- package/dist/_internal/scalar.d.ts.map +1 -0
- package/dist/box2.d.ts +35 -0
- package/dist/box2.d.ts.map +1 -0
- package/dist/box3.d.ts +76 -0
- package/dist/box3.d.ts.map +1 -0
- package/dist/circle2.d.ts +29 -0
- package/dist/circle2.d.ts.map +1 -0
- package/dist/color.d.ts +64 -0
- package/dist/color.d.ts.map +1 -0
- package/dist/easing.d.ts +18 -0
- package/dist/easing.d.ts.map +1 -0
- package/dist/euler.d.ts +51 -0
- package/dist/euler.d.ts.map +1 -0
- package/dist/f32-to-f16-bytes.d.ts +10 -0
- package/dist/f32-to-f16-bytes.d.ts.map +1 -0
- package/dist/frustum.d.ts +49 -0
- package/dist/frustum.d.ts.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +3688 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mat3.d.ts +70 -0
- package/dist/mat3.d.ts.map +1 -0
- package/dist/mat4.d.ts +329 -0
- package/dist/mat4.d.ts.map +1 -0
- package/dist/noise.d.ts +12 -0
- package/dist/noise.d.ts.map +1 -0
- package/dist/quat.d.ts +312 -0
- package/dist/quat.d.ts.map +1 -0
- package/dist/ray.d.ts +144 -0
- package/dist/ray.d.ts.map +1 -0
- package/dist/ray2.d.ts +38 -0
- package/dist/ray2.d.ts.map +1 -0
- package/dist/sphere.d.ts +41 -0
- package/dist/sphere.d.ts.map +1 -0
- package/dist/types.d.ts +57 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/vec2.d.ts +76 -0
- package/dist/vec2.d.ts.map +1 -0
- package/dist/vec3.d.ts +82 -0
- package/dist/vec3.d.ts.map +1 -0
- package/dist/vec4.d.ts +61 -0
- package/dist/vec4.d.ts.map +1 -0
- package/package.json +57 -0
- package/src/__tests__/_arbs.ts +149 -0
- package/src/__tests__/_fixtures.ts +118 -0
- package/src/__tests__/bounds2.test.ts +146 -0
- package/src/__tests__/box3.test.ts +277 -0
- package/src/__tests__/easing.test.ts +109 -0
- package/src/__tests__/euler.test-d.ts +63 -0
- package/src/__tests__/mat3.test-d.ts +47 -0
- package/src/__tests__/mat4.property.test.ts +256 -0
- package/src/__tests__/mat4.test-d.ts +130 -0
- package/src/__tests__/mat4.test.ts +162 -0
- package/src/__tests__/noise.test.ts +75 -0
- package/src/__tests__/quat.basis.test.ts +134 -0
- package/src/__tests__/quat.interpolation.test.ts +45 -0
- package/src/__tests__/quat.lookat.test.ts +103 -0
- package/src/__tests__/quat.property.test.ts +150 -0
- package/src/__tests__/quat.rotateaxis.test.ts +149 -0
- package/src/__tests__/quat.test-d.ts +138 -0
- package/src/__tests__/ray.property.test.ts +105 -0
- package/src/__tests__/ray.test.ts +539 -0
- package/src/__tests__/types.test-d.ts +64 -0
- package/src/__tests__/vec-catmull-rom.test.ts +125 -0
- package/src/__tests__/vec-smooth-damp.test.ts +167 -0
- package/src/__tests__/vec2.test-d.ts +59 -0
- package/src/__tests__/vec3.property.test.ts +72 -0
- package/src/__tests__/vec3.test-d.ts +65 -0
- package/src/__tests__/vec4.test-d.ts +61 -0
- package/src/_internal/epsilon.ts +29 -0
- package/src/_internal/scalar.ts +90 -0
- package/src/box2.ts +168 -0
- package/src/box3.ts +372 -0
- package/src/circle2.ts +134 -0
- package/src/color.ts +177 -0
- package/src/easing.ts +49 -0
- package/src/euler.ts +239 -0
- package/src/f32-to-f16-bytes.ts +71 -0
- package/src/frustum.ts +240 -0
- package/src/index.ts +65 -0
- package/src/mat3.ts +286 -0
- package/src/mat4.ts +1334 -0
- package/src/noise.ts +78 -0
- package/src/quat.ts +847 -0
- package/src/ray.ts +575 -0
- package/src/ray2.ts +198 -0
- package/src/sphere.ts +138 -0
- package/src/types.ts +78 -0
- package/src/vec2.ts +229 -0
- package/src/vec3.ts +294 -0
- package/src/vec4.ts +235 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// mat4.property.test.ts — fast-check property tests (M5 / T-031, AC-11)
|
|
2
|
+
//
|
|
3
|
+
// Category coverage (4 cases / 3 categories):
|
|
4
|
+
// - associativity ×1: (A·B)·C ≈ A·(B·C), numRuns:50 (single chained mul ~100ms)
|
|
5
|
+
// - identity ×2: I·M ≈ M, M·I ≈ M
|
|
6
|
+
// - invertibility ×1: M·M⁻¹ ≈ I (restricted to invertibleMat4Arb, TRS composition guards against singular)
|
|
7
|
+
//
|
|
8
|
+
// numRuns: default 100; chained-mul cases drop to 50 (D-P5 / wiki §7.3).
|
|
9
|
+
// EPS: mat4 single mul 1e-4 (EPS_MAT4_MUL); triple chained mul 1e-3 (EPS_MAT4_MUL3).
|
|
10
|
+
//
|
|
11
|
+
// Related: requirements §AC-11; plan-strategy D-P5; wiki/fast-check-property-test §5.1-§5.3.
|
|
12
|
+
|
|
13
|
+
import { fc, test } from '@fast-check/vitest';
|
|
14
|
+
import { describe, expect } from 'vitest';
|
|
15
|
+
import * as mat4 from '../mat4';
|
|
16
|
+
import type { Vec3 } from '../types';
|
|
17
|
+
import {
|
|
18
|
+
EPS_F32,
|
|
19
|
+
EPS_MAT4_MUL,
|
|
20
|
+
EPS_MAT4_MUL3,
|
|
21
|
+
invertibleMat4Arb,
|
|
22
|
+
mat4Arb,
|
|
23
|
+
unitVec3Arb,
|
|
24
|
+
vec3Arb,
|
|
25
|
+
} from './_arbs';
|
|
26
|
+
|
|
27
|
+
// process.env is read via globalThis to avoid pulling in the @types/node devDep (AC-16 / D-P13).
|
|
28
|
+
const NUM_RUNS = Number.parseInt(
|
|
29
|
+
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env
|
|
30
|
+
?.PROPERTY_NUM_RUNS ?? '100',
|
|
31
|
+
10,
|
|
32
|
+
);
|
|
33
|
+
// chained-mul cases drop to 1/2 numRuns, ~100ms × 50 = within 5s (vitest test timeout default)
|
|
34
|
+
const NUM_RUNS_TRIPLE = Math.max(10, Math.floor(NUM_RUNS / 2));
|
|
35
|
+
|
|
36
|
+
function mat4ApproxEq(a: Float32Array, b: Float32Array, eps: number): boolean {
|
|
37
|
+
for (let i = 0; i < 16; i++) {
|
|
38
|
+
const ai = a[i] as number;
|
|
39
|
+
const bi = b[i] as number;
|
|
40
|
+
const scale = Math.max(1, Math.abs(ai), Math.abs(bi));
|
|
41
|
+
if (Math.abs(ai - bi) >= eps * scale) return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('mat4 property: associativity', () => {
|
|
47
|
+
test.prop(
|
|
48
|
+
{ A: mat4Arb(), B: mat4Arb(), C: mat4Arb() },
|
|
49
|
+
{ numRuns: NUM_RUNS_TRIPLE, interruptAfterTimeLimit: 5000 },
|
|
50
|
+
)('(A·B)·C ≈ A·(B·C)', ({ A, B, C }) => {
|
|
51
|
+
const ab = mat4.create();
|
|
52
|
+
mat4.multiply(ab, A, B);
|
|
53
|
+
const left = mat4.create();
|
|
54
|
+
mat4.multiply(left, ab, C);
|
|
55
|
+
|
|
56
|
+
const bc = mat4.create();
|
|
57
|
+
mat4.multiply(bc, B, C);
|
|
58
|
+
const right = mat4.create();
|
|
59
|
+
mat4.multiply(right, A, bc);
|
|
60
|
+
|
|
61
|
+
return mat4ApproxEq(left, right, EPS_MAT4_MUL3);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('mat4 property: identity', () => {
|
|
66
|
+
test.prop({ M: mat4Arb() }, { numRuns: NUM_RUNS })('I · M ≈ M', ({ M }) => {
|
|
67
|
+
const I = mat4.create();
|
|
68
|
+
mat4.identity(I);
|
|
69
|
+
const out = mat4.create();
|
|
70
|
+
mat4.multiply(out, I, M);
|
|
71
|
+
return mat4ApproxEq(out, M, EPS_MAT4_MUL);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test.prop({ M: mat4Arb() }, { numRuns: NUM_RUNS })('M · I ≈ M', ({ M }) => {
|
|
75
|
+
const I = mat4.create();
|
|
76
|
+
mat4.identity(I);
|
|
77
|
+
const out = mat4.create();
|
|
78
|
+
mat4.multiply(out, M, I);
|
|
79
|
+
return mat4ApproxEq(out, M, EPS_MAT4_MUL);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('mat4 property: invertibility', () => {
|
|
84
|
+
test.prop({ M: invertibleMat4Arb() }, { numRuns: NUM_RUNS })(
|
|
85
|
+
'M · invert(M) ≈ I (restricted to invertible TRS compositions)',
|
|
86
|
+
({ M }) => {
|
|
87
|
+
const inv = mat4.create();
|
|
88
|
+
mat4.invert(inv, M);
|
|
89
|
+
const I = mat4.create();
|
|
90
|
+
mat4.multiply(I, M, inv);
|
|
91
|
+
const expected = mat4.create();
|
|
92
|
+
mat4.identity(expected);
|
|
93
|
+
// EPS slightly relaxed: TRS scale range [1e-2, 10] × invert error amplification
|
|
94
|
+
return mat4ApproxEq(I, expected, 1e-2);
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// M1 / t4 — mat4 transform 3-function property +6
|
|
100
|
+
//
|
|
101
|
+
// 6 cases / 2 per function:
|
|
102
|
+
// - transformVec3: (1) chained composition transformVec3(B*A, v) ≈ transformVec3(B, transformVec3(A, v))
|
|
103
|
+
// (2) identity unit transformVec3(I, v) ≈ v
|
|
104
|
+
// - transformPoint: (1) equivalent to transformVec3 on an affine m
|
|
105
|
+
// (2) translation independently testable: transformPoint(T, origin) = t
|
|
106
|
+
// - transformDirection: (1) orthogonal matrices preserve unit length
|
|
107
|
+
// (2) equivalent to transformVec3 on a pure-rotation m
|
|
108
|
+
//
|
|
109
|
+
// numRuns: 100 default; EPS aligned with wiki §4.4 recommendation table; interruptAfterTimeLimit:5000.
|
|
110
|
+
//
|
|
111
|
+
// Related: requirements §3.3 property +8 mandate (mat4 contributes 6 cases) + §6 AC-11 ≥ 20;
|
|
112
|
+
// research Finding 5 template table + plan-strategy §4.2 + §4.3 key test points table row 5.
|
|
113
|
+
|
|
114
|
+
function vec3ApproxEq(a: Float32Array, b: Float32Array, eps: number): boolean {
|
|
115
|
+
for (let i = 0; i < 3; i++) {
|
|
116
|
+
const ai = a[i] as number;
|
|
117
|
+
const bi = b[i] as number;
|
|
118
|
+
if (!Number.isFinite(ai) || !Number.isFinite(bi)) return false;
|
|
119
|
+
const scale = Math.max(1, Math.abs(ai), Math.abs(bi));
|
|
120
|
+
if (Math.abs(ai - bi) >= eps * scale) return false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('mat4.transformVec3 property (t4)', () => {
|
|
126
|
+
test.prop({ v: vec3Arb() }, { numRuns: NUM_RUNS })(
|
|
127
|
+
'identity unit: transformVec3(I, v) ≈ v',
|
|
128
|
+
({ v }) => {
|
|
129
|
+
const I = mat4.identity(mat4.create());
|
|
130
|
+
const out = new Float32Array(3) as Vec3;
|
|
131
|
+
mat4.transformVec3(out, I, v);
|
|
132
|
+
return vec3ApproxEq(out, v, EPS_F32);
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
test.prop(
|
|
137
|
+
{ A: invertibleMat4Arb(), B: invertibleMat4Arb(), v: vec3Arb() },
|
|
138
|
+
{ numRuns: NUM_RUNS, interruptAfterTimeLimit: 5000 },
|
|
139
|
+
)(
|
|
140
|
+
'chained composition: transformVec3(B*A, v) ≈ transformVec3(B, transformVec3(A, v))',
|
|
141
|
+
({ A, B, v }) => {
|
|
142
|
+
const BA = mat4.create();
|
|
143
|
+
mat4.multiply(BA, B, A);
|
|
144
|
+
const left = new Float32Array(3) as Vec3;
|
|
145
|
+
mat4.transformVec3(left, BA, v);
|
|
146
|
+
const tmp = new Float32Array(3) as Vec3;
|
|
147
|
+
mat4.transformVec3(tmp, A, v);
|
|
148
|
+
const right = new Float32Array(3) as Vec3;
|
|
149
|
+
mat4.transformVec3(right, B, tmp);
|
|
150
|
+
return vec3ApproxEq(left, right, EPS_MAT4_MUL);
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('mat4.transformPoint property (t4)', () => {
|
|
156
|
+
test.prop({ M: invertibleMat4Arb(), v: vec3Arb() }, { numRuns: NUM_RUNS })(
|
|
157
|
+
'equivalent to transformVec3 on an affine m (alias)',
|
|
158
|
+
({ M, v }) => {
|
|
159
|
+
const a = new Float32Array(3) as Vec3;
|
|
160
|
+
const b = new Float32Array(3) as Vec3;
|
|
161
|
+
mat4.transformVec3(a, M, v);
|
|
162
|
+
mat4.transformPoint(b, M, v);
|
|
163
|
+
return vec3ApproxEq(a, b, EPS_F32);
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
test.prop({ t: vec3Arb() }, { numRuns: NUM_RUNS })(
|
|
168
|
+
'translation independently testable: transformPoint(T(t), origin) = t',
|
|
169
|
+
({ t }) => {
|
|
170
|
+
const T = mat4.fromTranslation(mat4.create(), t);
|
|
171
|
+
const origin = Float32Array.of(0, 0, 0);
|
|
172
|
+
const out = new Float32Array(3) as Vec3;
|
|
173
|
+
mat4.transformPoint(out, T, origin);
|
|
174
|
+
return vec3ApproxEq(out, t, EPS_F32);
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('mat4.transformDirection property (t4)', () => {
|
|
180
|
+
test.prop({ axis: unitVec3Arb(), dir: unitVec3Arb() }, { numRuns: NUM_RUNS })(
|
|
181
|
+
'orthogonal matrices preserve unit length: |transformDirection(R, dir)| ≈ 1',
|
|
182
|
+
({ axis, dir }) => {
|
|
183
|
+
const R = mat4.fromRotation(mat4.create(), axis, 0.7);
|
|
184
|
+
const out = new Float32Array(3) as Vec3;
|
|
185
|
+
mat4.transformDirection(out, R, dir);
|
|
186
|
+
const len = Math.hypot(out[0] as number, out[1] as number, out[2] as number);
|
|
187
|
+
return Math.abs(len - 1) < EPS_F32 * 10;
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
test.prop({ axis: unitVec3Arb(), dir: unitVec3Arb() }, { numRuns: NUM_RUNS })(
|
|
192
|
+
'equivalent to transformVec3 on a pure-rotation m (normalize does not affect unit length)',
|
|
193
|
+
({ axis, dir }) => {
|
|
194
|
+
const R = mat4.fromRotation(mat4.create(), axis, 0.5);
|
|
195
|
+
const a = new Float32Array(3) as Vec3;
|
|
196
|
+
const b = new Float32Array(3) as Vec3;
|
|
197
|
+
mat4.transformVec3(a, R, dir);
|
|
198
|
+
mat4.transformDirection(b, R, dir);
|
|
199
|
+
return vec3ApproxEq(a, b, EPS_F32 * 10);
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// G-4 — projectPoint duality with unproject (roadmap 2026-06-15 game-demo-engine-gaps)
|
|
205
|
+
//
|
|
206
|
+
// Two cases:
|
|
207
|
+
// (1) intent-revealing alias: projectPoint(out, v, VP) ≡ transformVec3(out, VP, v)
|
|
208
|
+
// — sanity check the wrapper does not introduce divergence vs the underlying op
|
|
209
|
+
// (2) round-trip on a realistic view-projection (lookAt + perspective):
|
|
210
|
+
// unproject(projectPoint(v, VP), invVP) ≈ v for v inside the frustum.
|
|
211
|
+
// Restricted to in-frustum points — extreme invertibleMat4Arb matrices amplify
|
|
212
|
+
// f32 error past any reasonable epsilon, and HUD anchoring only ever cares about
|
|
213
|
+
// points the camera can actually see.
|
|
214
|
+
describe('mat4.projectPoint property (G-4 dual of unproject)', () => {
|
|
215
|
+
test.prop({ VP: invertibleMat4Arb(), v: vec3Arb() }, { numRuns: NUM_RUNS })(
|
|
216
|
+
'projectPoint(out, v, VP) ≡ transformVec3(out, VP, v)',
|
|
217
|
+
({ VP, v }) => {
|
|
218
|
+
const a = new Float32Array(3) as Vec3;
|
|
219
|
+
const b = new Float32Array(3) as Vec3;
|
|
220
|
+
mat4.projectPoint(a, v, VP);
|
|
221
|
+
mat4.transformVec3(b, VP, v);
|
|
222
|
+
return vec3ApproxEq(a, b, EPS_F32);
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
test.prop(
|
|
227
|
+
{
|
|
228
|
+
// sample world points roughly inside the frustum the VP below describes.
|
|
229
|
+
v: fc
|
|
230
|
+
.tuple(
|
|
231
|
+
fc.float({ min: -2, max: 2, noNaN: true }),
|
|
232
|
+
fc.float({ min: -2, max: 2, noNaN: true }),
|
|
233
|
+
fc.float({ min: -8, max: -2, noNaN: true }),
|
|
234
|
+
)
|
|
235
|
+
.map(([x, y, z]) => Float32Array.of(x, y, z) as Vec3),
|
|
236
|
+
},
|
|
237
|
+
{ numRuns: NUM_RUNS },
|
|
238
|
+
)('round-trip on a realistic view-projection: unproject(projectPoint(v, VP)) ≈ v', ({ v }) => {
|
|
239
|
+
const view = mat4.lookAt(mat4.create(), [0, 0, 0], [0, 0, -1], [0, 1, 0]);
|
|
240
|
+
const proj = mat4.perspective(mat4.create(), Math.PI / 3, 16 / 9, 0.1, 100);
|
|
241
|
+
const VP = mat4.create();
|
|
242
|
+
mat4.multiply(VP, proj, view);
|
|
243
|
+
const invVP = mat4.create();
|
|
244
|
+
mat4.invert(invVP, VP);
|
|
245
|
+
|
|
246
|
+
const ndc = new Float32Array(3) as Vec3;
|
|
247
|
+
mat4.projectPoint(ndc, v, VP);
|
|
248
|
+
const back = new Float32Array(3) as Vec3;
|
|
249
|
+
mat4.unproject(back, ndc, invVP);
|
|
250
|
+
// round-trip across two w-divides + invert; relax to EPS_MAT4_MUL3 (1e-3).
|
|
251
|
+
return vec3ApproxEq(back, v, EPS_MAT4_MUL3);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
void fc;
|
|
256
|
+
void expect;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// mat4.test-d.ts — branded return type + dimension-mix-up compile-time assertions (T-018)
|
|
2
|
+
//
|
|
3
|
+
// Locks multiply / invert / transpose to return Mat4; dimension mix-ups
|
|
4
|
+
// (mat4.multiply(out, vec3, vec3)) are ts-expect-error; lookAt's Vec3 inputs are also type-locked.
|
|
5
|
+
//
|
|
6
|
+
// Related: requirements §AC-12 type test + AC-02 brand mutual exclusion;
|
|
7
|
+
// research §Finding 7.3 dimension-mix-up call → ts-error template.
|
|
8
|
+
|
|
9
|
+
import { describe, expectTypeOf, it } from 'vitest';
|
|
10
|
+
import { mat4 } from '../index';
|
|
11
|
+
import type { Mat3, Mat4, Quat, Vec3, Vec4 } from '../types';
|
|
12
|
+
|
|
13
|
+
describe('mat4 — branded return-type locks', () => {
|
|
14
|
+
it('mat4.create returns Mat4', () => {
|
|
15
|
+
expectTypeOf(mat4.create()).toEqualTypeOf<Mat4>();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('mat4.identity returns Mat4', () => {
|
|
19
|
+
expectTypeOf(mat4.identity(mat4.create())).toEqualTypeOf<Mat4>();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('mat4.multiply returns Mat4', () => {
|
|
23
|
+
expectTypeOf(mat4.multiply(mat4.create(), mat4.create(), mat4.create())).toEqualTypeOf<Mat4>();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('mat4.transpose returns Mat4', () => {
|
|
27
|
+
expectTypeOf(mat4.transpose(mat4.create(), mat4.create())).toEqualTypeOf<Mat4>();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('mat4.invert returns Mat4 (does not return null, AC-08 cross-vendor rewrite)', () => {
|
|
31
|
+
expectTypeOf(mat4.invert(mat4.create(), mat4.create())).toEqualTypeOf<Mat4>();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('mat4.perspective returns Mat4', () => {
|
|
35
|
+
expectTypeOf(mat4.perspective(mat4.create(), 1, 1, 0.1, 100)).toEqualTypeOf<Mat4>();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('mat4.perspectiveReverseZ returns Mat4', () => {
|
|
39
|
+
expectTypeOf(mat4.perspectiveReverseZ(mat4.create(), 1, 1, 0.1, 100)).toEqualTypeOf<Mat4>();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('mat4 — cross-namespace misuse blocked at compile time (out brand lock)', () => {
|
|
44
|
+
it('mat4.identity must reject Mat3 as out', () => {
|
|
45
|
+
const m3: Mat3 = null as unknown as Mat3;
|
|
46
|
+
// @ts-expect-error — out must be Mat4, not Mat3
|
|
47
|
+
mat4.identity(m3);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('mat4.multiply must reject Vec3 as out', () => {
|
|
51
|
+
const v3: Vec3 = null as unknown as Vec3;
|
|
52
|
+
const m4: Mat4 = mat4.create();
|
|
53
|
+
// @ts-expect-error — out must be Mat4, not Vec3
|
|
54
|
+
mat4.multiply(v3, m4, m4);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('mat4.invert must reject Mat3 as out', () => {
|
|
58
|
+
const m3: Mat3 = null as unknown as Mat3;
|
|
59
|
+
const m4: Mat4 = mat4.create();
|
|
60
|
+
// @ts-expect-error — out must be Mat4, not Mat3
|
|
61
|
+
mat4.invert(m3, m4);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// M1 / t3 — mat4 transform 3-function brand mutual exclusion + return type + alias type consistency
|
|
66
|
+
//
|
|
67
|
+
// Related: requirements §6 AC-15 + §10.1 brand-safety row;
|
|
68
|
+
// research Finding 4 table: brand type mismatch → compile-time TS error;
|
|
69
|
+
// plan-strategy §4.3 key test points table row 6 + §7.1 brand safety.
|
|
70
|
+
describe('mat4 transform 3 functions — branded return type + brand mutual exclusion (M1 / t3)', () => {
|
|
71
|
+
it('mat4.transformVec3 returns Vec3', () => {
|
|
72
|
+
const v: Vec3 = null as unknown as Vec3;
|
|
73
|
+
const m: Mat4 = mat4.create();
|
|
74
|
+
expectTypeOf(mat4.transformVec3(v, m, v)).toEqualTypeOf<Vec3>();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('mat4.transformPoint returns Vec3', () => {
|
|
78
|
+
const v: Vec3 = null as unknown as Vec3;
|
|
79
|
+
const m: Mat4 = mat4.create();
|
|
80
|
+
expectTypeOf(mat4.transformPoint(v, m, v)).toEqualTypeOf<Vec3>();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('mat4.transformDirection returns Vec3', () => {
|
|
84
|
+
const v: Vec3 = null as unknown as Vec3;
|
|
85
|
+
const m: Mat4 = mat4.create();
|
|
86
|
+
expectTypeOf(mat4.transformDirection(v, m, v)).toEqualTypeOf<Vec3>();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('transformPoint shares the same type signature as transformVec3 (alias form, S-1)', () => {
|
|
90
|
+
expectTypeOf(mat4.transformPoint).toEqualTypeOf<typeof mat4.transformVec3>();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('mat4.transformVec3 must reject Vec4 as out (Vec3 ≠ Vec4 brand mutual exclusion)', () => {
|
|
94
|
+
const v4: Vec4 = null as unknown as Vec4;
|
|
95
|
+
const m: Mat4 = mat4.create();
|
|
96
|
+
const v3: Vec3 = null as unknown as Vec3;
|
|
97
|
+
// @ts-expect-error — out must be Vec3, not Vec4
|
|
98
|
+
mat4.transformVec3(v4, m, v3);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('mat4.transformPoint must reject Quat as out (Vec3 ≠ Quat brand mutual exclusion)', () => {
|
|
102
|
+
const q: Quat = null as unknown as Quat;
|
|
103
|
+
const m: Mat4 = mat4.create();
|
|
104
|
+
const v3: Vec3 = null as unknown as Vec3;
|
|
105
|
+
// @ts-expect-error — out must be Vec3, not Quat
|
|
106
|
+
mat4.transformPoint(q, m, v3);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('mat4.transformDirection must reject Mat4 as out (Vec3 ≠ Mat4 brand mutual exclusion)', () => {
|
|
110
|
+
const m4: Mat4 = mat4.create();
|
|
111
|
+
const v3: Vec3 = null as unknown as Vec3;
|
|
112
|
+
// @ts-expect-error — out must be Vec3, not Mat4
|
|
113
|
+
mat4.transformDirection(m4, m4, v3);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// G-4 (roadmap 2026-06-15 game-demo-engine-gaps) — projectPoint dual of unproject
|
|
117
|
+
it('mat4.projectPoint returns Vec3', () => {
|
|
118
|
+
const v: Vec3 = null as unknown as Vec3;
|
|
119
|
+
const m: Mat4 = mat4.create();
|
|
120
|
+
expectTypeOf(mat4.projectPoint(v, v, m)).toEqualTypeOf<Vec3>();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('mat4.projectPoint must reject Quat as out (Vec3 ≠ Quat brand mutual exclusion)', () => {
|
|
124
|
+
const q: Quat = null as unknown as Quat;
|
|
125
|
+
const m: Mat4 = mat4.create();
|
|
126
|
+
const v3: Vec3 = null as unknown as Vec3;
|
|
127
|
+
// @ts-expect-error — out must be Vec3, not Quat
|
|
128
|
+
mat4.projectPoint(q, v3, m);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// mat4.test.ts — mat4 unit tests (feat-20260617-host-engine-contract-and-video-cutscene M2 w4)
|
|
2
|
+
//
|
|
3
|
+
// Coverage: computeViewProj elementwise equals hand-computed mat4.perspective * mat4.lookAt
|
|
4
|
+
// (EPS_MAT4_MUL3 = 1e-3), >= 3 param combos. Multiple fov/aspect/near/far and eye/target poses.
|
|
5
|
+
// Test must NOT import any runtime type (zero-dep guard, plan-strategy §5.6).
|
|
6
|
+
//
|
|
7
|
+
// TDD: this file is committed RED (computeViewProj not yet implemented in mat4.ts);
|
|
8
|
+
// w6 implementation greens these tests.
|
|
9
|
+
//
|
|
10
|
+
// Related: requirements AC-03; plan-strategy D-1; research Finding 4/5.
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
import * as mat4 from '../mat4';
|
|
14
|
+
import type { Mat4 } from '../types';
|
|
15
|
+
|
|
16
|
+
// ============================================================
|
|
17
|
+
// Helpers
|
|
18
|
+
// ============================================================
|
|
19
|
+
|
|
20
|
+
/** element-wise approximate equality with relative scaling */
|
|
21
|
+
function mat4ApproxEq(a: Float32Array, b: Float32Array, eps: number): boolean {
|
|
22
|
+
for (let i = 0; i < 16; i++) {
|
|
23
|
+
const ai = a[i] as number;
|
|
24
|
+
const bi = b[i] as number;
|
|
25
|
+
const scale = Math.max(1, Math.abs(ai), Math.abs(bi));
|
|
26
|
+
if (Math.abs(ai - bi) >= eps * scale) return false;
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const EPS_MAT4_MUL3 = 1e-3;
|
|
32
|
+
|
|
33
|
+
/** Build VP via hand-composed lookAt + perspective + multiply (the reference). */
|
|
34
|
+
function refVP(
|
|
35
|
+
out: Mat4,
|
|
36
|
+
eye: [number, number, number],
|
|
37
|
+
target: [number, number, number],
|
|
38
|
+
up: [number, number, number],
|
|
39
|
+
fov: number,
|
|
40
|
+
aspect: number,
|
|
41
|
+
near: number,
|
|
42
|
+
far: number,
|
|
43
|
+
): Mat4 {
|
|
44
|
+
const view = mat4.lookAt(mat4.create(), eye, target, up);
|
|
45
|
+
const proj = mat4.perspective(mat4.create(), fov, aspect, near, far);
|
|
46
|
+
return mat4.multiply(out, proj, view);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ============================================================
|
|
50
|
+
// Param combo 1: default forward-looking camera
|
|
51
|
+
// ============================================================
|
|
52
|
+
|
|
53
|
+
describe('mat4.computeViewProj', () => {
|
|
54
|
+
it('combo 1: camera at origin, looking at -z → equals proj * lookAt', () => {
|
|
55
|
+
const eye: [number, number, number] = [0, 0, 0];
|
|
56
|
+
const target: [number, number, number] = [0, 0, -1];
|
|
57
|
+
const up: [number, number, number] = [0, 1, 0];
|
|
58
|
+
const fov = Math.PI / 3;
|
|
59
|
+
const aspect = 800 / 600;
|
|
60
|
+
const near = 0.1;
|
|
61
|
+
const far = 100;
|
|
62
|
+
|
|
63
|
+
const result = mat4.create();
|
|
64
|
+
mat4.computeViewProj(result, eye, target, up, fov, aspect, near, far);
|
|
65
|
+
|
|
66
|
+
const expected = mat4.create();
|
|
67
|
+
refVP(expected, eye, target, up, fov, aspect, near, far);
|
|
68
|
+
|
|
69
|
+
expect(mat4ApproxEq(result, expected, EPS_MAT4_MUL3)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ============================================================
|
|
73
|
+
// Param combo 2: elevated camera looking at world origin
|
|
74
|
+
// ============================================================
|
|
75
|
+
|
|
76
|
+
it('combo 2: elevated camera looking at origin → equals proj * lookAt', () => {
|
|
77
|
+
const eye: [number, number, number] = [0, 5, 10];
|
|
78
|
+
const target: [number, number, number] = [0, 0, 0];
|
|
79
|
+
const up: [number, number, number] = [0, 1, 0];
|
|
80
|
+
const fov = Math.PI / 4;
|
|
81
|
+
const aspect = 16 / 9;
|
|
82
|
+
const near = 0.1;
|
|
83
|
+
const far = 100;
|
|
84
|
+
|
|
85
|
+
const result = mat4.create();
|
|
86
|
+
mat4.computeViewProj(result, eye, target, up, fov, aspect, near, far);
|
|
87
|
+
|
|
88
|
+
const expected = mat4.create();
|
|
89
|
+
refVP(expected, eye, target, up, fov, aspect, near, far);
|
|
90
|
+
|
|
91
|
+
expect(mat4ApproxEq(result, expected, EPS_MAT4_MUL3)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ============================================================
|
|
95
|
+
// Param combo 3: off-axis camera with narrow FOV and large near/far
|
|
96
|
+
// ============================================================
|
|
97
|
+
|
|
98
|
+
it('combo 3: off-axis camera, narrow fov, large range → equals proj * lookAt', () => {
|
|
99
|
+
const eye: [number, number, number] = [10, 3, -20];
|
|
100
|
+
const target: [number, number, number] = [0, 0, 0];
|
|
101
|
+
const up: [number, number, number] = [0, 1, 0.5];
|
|
102
|
+
const fov = Math.PI / 6; // 30 deg — narrow
|
|
103
|
+
const aspect = 4 / 3;
|
|
104
|
+
const near = 1;
|
|
105
|
+
const far = 500;
|
|
106
|
+
|
|
107
|
+
const result = mat4.create();
|
|
108
|
+
mat4.computeViewProj(result, eye, target, up, fov, aspect, near, far);
|
|
109
|
+
|
|
110
|
+
const expected = mat4.create();
|
|
111
|
+
refVP(expected, eye, target, up, fov, aspect, near, far);
|
|
112
|
+
|
|
113
|
+
expect(mat4ApproxEq(result, expected, EPS_MAT4_MUL3)).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ============================================================
|
|
117
|
+
// Param combo 4: infinite far plane
|
|
118
|
+
// ============================================================
|
|
119
|
+
|
|
120
|
+
it('combo 4: infinite far plane → equals proj * lookAt', () => {
|
|
121
|
+
const eye: [number, number, number] = [0, 0, 0];
|
|
122
|
+
const target: [number, number, number] = [0, 0, -1];
|
|
123
|
+
const up: [number, number, number] = [0, 1, 0];
|
|
124
|
+
const fov = Math.PI / 3;
|
|
125
|
+
const aspect = 800 / 600;
|
|
126
|
+
const near = 0.1;
|
|
127
|
+
const far = Number.POSITIVE_INFINITY;
|
|
128
|
+
|
|
129
|
+
const result = mat4.create();
|
|
130
|
+
mat4.computeViewProj(result, eye, target, up, fov, aspect, near, far);
|
|
131
|
+
|
|
132
|
+
const expected = mat4.create();
|
|
133
|
+
refVP(expected, eye, target, up, fov, aspect, near, far);
|
|
134
|
+
|
|
135
|
+
expect(mat4ApproxEq(result, expected, EPS_MAT4_MUL3)).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ============================================================
|
|
139
|
+
// Param combo 5: close-to-identity view (eye almost on target → identity)
|
|
140
|
+
// ============================================================
|
|
141
|
+
|
|
142
|
+
it('combo 5: degenerate eye=target → lookAt returns identity, VP = proj * I = proj', () => {
|
|
143
|
+
const eye: [number, number, number] = [0, 0, 0];
|
|
144
|
+
const target: [number, number, number] = [0, 0, 0];
|
|
145
|
+
const up: [number, number, number] = [0, 1, 0];
|
|
146
|
+
const fov = Math.PI / 3;
|
|
147
|
+
const aspect = 800 / 600;
|
|
148
|
+
const near = 0.1;
|
|
149
|
+
const far = 100;
|
|
150
|
+
|
|
151
|
+
const result = mat4.create();
|
|
152
|
+
mat4.computeViewProj(result, eye, target, up, fov, aspect, near, far);
|
|
153
|
+
|
|
154
|
+
// When eye=target, lookAt returns identity → VP = proj * I = proj
|
|
155
|
+
const expected = mat4.create();
|
|
156
|
+
mat4.perspective(expected, fov, aspect, near, far);
|
|
157
|
+
|
|
158
|
+
expect(mat4ApproxEq(result, expected, EPS_MAT4_MUL3)).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
void expect;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// noise.test.ts — value tests for the noise namespace (solo round 20260716-212942)
|
|
2
|
+
//
|
|
3
|
+
// Regression guard for the 1D Perlin noise function used by camera shake and procedural
|
|
4
|
+
// effects. These tests pin:
|
|
5
|
+
// 1. output in [-1, 1],
|
|
6
|
+
// 2. perlin1d is deterministic (same input → same output),
|
|
7
|
+
// 3. perlin1d is smooth (nearby inputs → nearby outputs, the defining noise property),
|
|
8
|
+
// 4. offset channels like x, x+100, x+200 are independent (different values),
|
|
9
|
+
// 5. integer lattice points return 0 (the gradient dot at distance 0).
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { noise } from '../index';
|
|
13
|
+
|
|
14
|
+
const { perlin1d } = noise;
|
|
15
|
+
|
|
16
|
+
describe('noise.perlin1d', () => {
|
|
17
|
+
it('returns values in [-1, 1]', () => {
|
|
18
|
+
for (let x = 0; x < 500; x += 0.137) {
|
|
19
|
+
const v = perlin1d(x);
|
|
20
|
+
expect(v).toBeGreaterThanOrEqual(-1);
|
|
21
|
+
expect(v).toBeLessThanOrEqual(1);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('is deterministic', () => {
|
|
26
|
+
expect(perlin1d(3.14)).toBe(perlin1d(3.14));
|
|
27
|
+
expect(perlin1d(42)).toBe(perlin1d(42));
|
|
28
|
+
expect(perlin1d(0)).toBe(perlin1d(0));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('is smooth: nearby inputs yield nearby outputs', () => {
|
|
32
|
+
for (let x = 1; x < 100; x += 0.7) {
|
|
33
|
+
const a = perlin1d(x);
|
|
34
|
+
const b = perlin1d(x + 0.001);
|
|
35
|
+
// The fade curve is smooth, so a tiny input change should produce a tiny output change.
|
|
36
|
+
expect(Math.abs(a - b)).toBeLessThan(0.05);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('offset channels are independent (x, x+100, x+200)', () => {
|
|
41
|
+
// Use a non-integer input so the channels aren't all zero.
|
|
42
|
+
const a = perlin1d(50.5);
|
|
43
|
+
const b = perlin1d(150.5); // 50.5 + 100
|
|
44
|
+
const c = perlin1d(250.5); // 50.5 + 200
|
|
45
|
+
// They should not all be the same — the offset separates the channels.
|
|
46
|
+
const allSame = a === b && b === c;
|
|
47
|
+
expect(allSame).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('integer lattice points return 0 (gradient dot at distance 0)', () => {
|
|
51
|
+
for (let x = 0; x < 256; x++) {
|
|
52
|
+
expect(perlin1d(x)).toBeCloseTo(0, 10);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('integer lattice points return 0 at negative inputs too', () => {
|
|
57
|
+
expect(perlin1d(-1)).toBeCloseTo(0, 10);
|
|
58
|
+
expect(perlin1d(-42)).toBeCloseTo(0, 10);
|
|
59
|
+
expect(perlin1d(-256)).toBeCloseTo(0, 10);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('symmetry: the same fractional offset from any integer gives the same absolute value', () => {
|
|
63
|
+
// perlin1d works on the fractional part relative to the floor — the integer part
|
|
64
|
+
// determines which hash bucket, so outputs differ between different integer segments.
|
|
65
|
+
// But the interpolation *pattern* is consistent: at the same fractional offset t, the
|
|
66
|
+
// output is somewhere between two lattice values that cross the segment.
|
|
67
|
+
// We just verify it's not NaN or out-of-range.
|
|
68
|
+
for (let x = -10; x < 10; x += 0.13) {
|
|
69
|
+
const v = perlin1d(x);
|
|
70
|
+
expect(Number.isNaN(v)).toBe(false);
|
|
71
|
+
expect(v).toBeGreaterThanOrEqual(-1);
|
|
72
|
+
expect(v).toBeLessThanOrEqual(1);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|