@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,539 @@
|
|
|
1
|
+
// ray.test.ts — ray unit tests (feat-20260617-host-engine-contract-and-video-cutscene M2)
|
|
2
|
+
//
|
|
3
|
+
// Coverage: worldToScreen behind / onScreen / normal three-state, y-down pixel mapping,
|
|
4
|
+
// finite-input / zero-canvas degenerate edges. 100% branch coverage target.
|
|
5
|
+
//
|
|
6
|
+
// worldToScreen TDD: this file is committed RED (worldToScreen not yet implemented in ray.ts);
|
|
7
|
+
// w5 implementation greens these tests.
|
|
8
|
+
//
|
|
9
|
+
// Related: requirements AC-01; plan-tasks.json w3 acceptanceCheck;
|
|
10
|
+
// research Finding 3 (projectPoint discards w).
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
import * as mat4 from '../mat4';
|
|
14
|
+
import { rayTriangleIntersects, worldToScreen } from '../ray';
|
|
15
|
+
import type { Mat4 } from '../types';
|
|
16
|
+
import * as vec2 from '../vec2';
|
|
17
|
+
|
|
18
|
+
// ============================================================
|
|
19
|
+
// Helpers
|
|
20
|
+
// ============================================================
|
|
21
|
+
|
|
22
|
+
/** Build a simple perspective view-projection: camera at origin, looking at -z. */
|
|
23
|
+
function makeViewProj(
|
|
24
|
+
out: Mat4,
|
|
25
|
+
fovYRadians = Math.PI / 3,
|
|
26
|
+
aspect = 800 / 600,
|
|
27
|
+
near = 0.1,
|
|
28
|
+
far = 100,
|
|
29
|
+
): Mat4 {
|
|
30
|
+
const view = mat4.lookAt(mat4.create(), [0, 0, 0], [0, 0, -1], [0, 1, 0]);
|
|
31
|
+
const proj = mat4.perspective(mat4.create(), fovYRadians, aspect, near, far);
|
|
32
|
+
return mat4.multiply(out, proj, view);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ============================================================
|
|
36
|
+
// Three-state tests: behind / onScreen / normal
|
|
37
|
+
// ============================================================
|
|
38
|
+
|
|
39
|
+
describe('ray.worldToScreen — three-state', () => {
|
|
40
|
+
it('behind: point behind camera (w<0) → behind=true, onScreen ignored', () => {
|
|
41
|
+
const VP = mat4.create();
|
|
42
|
+
makeViewProj(VP);
|
|
43
|
+
|
|
44
|
+
const out = vec2.create();
|
|
45
|
+
const result = worldToScreen(out, [0, 0, 5], VP, 800, 600);
|
|
46
|
+
|
|
47
|
+
expect(result.behind).toBe(true);
|
|
48
|
+
// behind=true → onScreen is meaningless (requirements §7: out is undefined)
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('onScreen=false: point in front but outside frustum → onScreen=false, behind=false', () => {
|
|
52
|
+
const VP = mat4.create();
|
|
53
|
+
makeViewProj(VP);
|
|
54
|
+
|
|
55
|
+
const out = vec2.create();
|
|
56
|
+
// (10, 0, -5): in front of camera (z=-5) but far outside the NDC x bound
|
|
57
|
+
const result = worldToScreen(out, [10, 0, -5], VP, 800, 600);
|
|
58
|
+
|
|
59
|
+
expect(result.behind).toBe(false);
|
|
60
|
+
expect(result.onScreen).toBe(false);
|
|
61
|
+
// onScreen=false → out is still valid pixel for screen-edge clamping (requirements §7)
|
|
62
|
+
expect(out[0]).toBeGreaterThan(800); // x maps past the right edge
|
|
63
|
+
expect(out[1]).toBeCloseTo(300, -1); // y should be roughly centre
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('normal: point inside frustum → onScreen=true, behind=false, pixel is correct', () => {
|
|
67
|
+
const VP = mat4.create();
|
|
68
|
+
makeViewProj(VP, Math.PI / 3, 800 / 600, 0.1, 100);
|
|
69
|
+
|
|
70
|
+
const out = vec2.create();
|
|
71
|
+
// (0, 0, -5): directly in front of camera along -z axis
|
|
72
|
+
const result = worldToScreen(out, [0, 0, -5], VP, 800, 600);
|
|
73
|
+
|
|
74
|
+
expect(result.behind).toBe(false);
|
|
75
|
+
expect(result.onScreen).toBe(true);
|
|
76
|
+
// Centre of screen: (400, 300) for 800×600 canvas
|
|
77
|
+
expect(out[0]).toBeCloseTo(400, -1);
|
|
78
|
+
expect(out[1]).toBeCloseTo(300, -1);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ============================================================
|
|
83
|
+
// y-down pixel mapping
|
|
84
|
+
// ============================================================
|
|
85
|
+
|
|
86
|
+
describe('ray.worldToScreen — y-down pixel mapping', () => {
|
|
87
|
+
it('maps NDC top (-y) to small pixel y, NDC bottom (+y) to large pixel y', () => {
|
|
88
|
+
const VP = mat4.create();
|
|
89
|
+
makeViewProj(VP, Math.PI / 3, 800 / 600, 0.1, 100);
|
|
90
|
+
|
|
91
|
+
// Point above camera: y=2, z=-5. In view space, y>0 means above centre.
|
|
92
|
+
// In NDC with perspective, y>0 maps to positive NDC, which with y-down
|
|
93
|
+
// pixel mapping means smaller pixel y (near top of canvas).
|
|
94
|
+
const top = vec2.create();
|
|
95
|
+
const bottom = vec2.create();
|
|
96
|
+
|
|
97
|
+
worldToScreen(top, [0, 2, -5], VP, 800, 600);
|
|
98
|
+
worldToScreen(bottom, [0, -2, -5], VP, 800, 600);
|
|
99
|
+
|
|
100
|
+
// y-down: top of screen = small pixel y, bottom = large pixel y
|
|
101
|
+
// point at +y in view space → top of screen → smaller pixel y
|
|
102
|
+
expect(top[1] as number).toBeLessThan(bottom[1] as number);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('pixel x grows rightward (left NDC → small x, right NDC → large x)', () => {
|
|
106
|
+
const VP = mat4.create();
|
|
107
|
+
makeViewProj(VP, Math.PI / 3, 800 / 600, 0.1, 100);
|
|
108
|
+
|
|
109
|
+
const left = vec2.create();
|
|
110
|
+
const right = vec2.create();
|
|
111
|
+
|
|
112
|
+
worldToScreen(left, [-2, 0, -5], VP, 800, 600);
|
|
113
|
+
worldToScreen(right, [2, 0, -5], VP, 800, 600);
|
|
114
|
+
|
|
115
|
+
// left side maps to smaller pixel x
|
|
116
|
+
expect(left[0] as number).toBeLessThan(right[0] as number);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ============================================================
|
|
121
|
+
// Degenerate edges
|
|
122
|
+
// ============================================================
|
|
123
|
+
|
|
124
|
+
describe('ray.worldToScreen — degenerate edges', () => {
|
|
125
|
+
it('zero canvas width → onScreen=false (degenerate viewport)', () => {
|
|
126
|
+
const VP = mat4.create();
|
|
127
|
+
makeViewProj(VP);
|
|
128
|
+
|
|
129
|
+
const out = vec2.create();
|
|
130
|
+
// Canvas width=0 is a degenerate viewport (detached/display:none)
|
|
131
|
+
const result = worldToScreen(out, [0, 0, -5], VP, 0, 600);
|
|
132
|
+
|
|
133
|
+
expect(result.onScreen).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('zero canvas height → onScreen=false (degenerate viewport)', () => {
|
|
137
|
+
const VP = mat4.create();
|
|
138
|
+
makeViewProj(VP);
|
|
139
|
+
|
|
140
|
+
const out = vec2.create();
|
|
141
|
+
const result = worldToScreen(out, [0, 0, -5], VP, 800, 0);
|
|
142
|
+
|
|
143
|
+
expect(result.onScreen).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('zero canvas both → onScreen=false, behind=false, out unchanged from input', () => {
|
|
147
|
+
const VP = mat4.create();
|
|
148
|
+
makeViewProj(VP);
|
|
149
|
+
|
|
150
|
+
const out = vec2.create(-99, -99);
|
|
151
|
+
const result = worldToScreen(out, [0, 0, -5], VP, 0, 0);
|
|
152
|
+
|
|
153
|
+
expect(result.behind).toBe(false);
|
|
154
|
+
expect(result.onScreen).toBe(false);
|
|
155
|
+
// out should not be mutated when canvas is degenerate
|
|
156
|
+
expect(out[0]).toBe(-99);
|
|
157
|
+
expect(out[1]).toBe(-99);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('negative canvas width → onScreen=false', () => {
|
|
161
|
+
const VP = mat4.create();
|
|
162
|
+
makeViewProj(VP);
|
|
163
|
+
|
|
164
|
+
const out = vec2.create();
|
|
165
|
+
const result = worldToScreen(out, [0, 0, -5], VP, -1, 600);
|
|
166
|
+
|
|
167
|
+
expect(result.onScreen).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('negative canvas height → onScreen=false', () => {
|
|
171
|
+
const VP = mat4.create();
|
|
172
|
+
makeViewProj(VP);
|
|
173
|
+
|
|
174
|
+
const out = vec2.create();
|
|
175
|
+
const result = worldToScreen(out, [0, 0, -5], VP, 800, -1);
|
|
176
|
+
|
|
177
|
+
expect(result.onScreen).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ============================================================
|
|
182
|
+
// Multiple viewProj configs (parametric correctness)
|
|
183
|
+
// ============================================================
|
|
184
|
+
|
|
185
|
+
describe('ray.worldToScreen — multiple viewProj', () => {
|
|
186
|
+
it('different camera pose: camera at (0,5,10) looking at (0,0,0) → origin maps to centre', () => {
|
|
187
|
+
const view = mat4.lookAt(mat4.create(), [0, 5, 10], [0, 0, 0], [0, 1, 0]);
|
|
188
|
+
const proj = mat4.perspective(mat4.create(), Math.PI / 3, 800 / 600, 0.1, 100);
|
|
189
|
+
const VP = mat4.create();
|
|
190
|
+
mat4.multiply(VP, proj, view);
|
|
191
|
+
|
|
192
|
+
const out = vec2.create();
|
|
193
|
+
const result = worldToScreen(out, [0, 0, 0], VP, 800, 600);
|
|
194
|
+
|
|
195
|
+
expect(result.behind).toBe(false);
|
|
196
|
+
expect(result.onScreen).toBe(true);
|
|
197
|
+
// World origin is at the centre of the view for a camera looking at it
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('wide aspect ratio (2:1) pixel mapping', () => {
|
|
201
|
+
const VP = mat4.create();
|
|
202
|
+
makeViewProj(VP, Math.PI / 3, 2, 0.1, 100);
|
|
203
|
+
|
|
204
|
+
const out = vec2.create();
|
|
205
|
+
const result = worldToScreen(out, [0, 0, -5], VP, 1200, 600);
|
|
206
|
+
|
|
207
|
+
expect(result.behind).toBe(false);
|
|
208
|
+
expect(result.onScreen).toBe(true);
|
|
209
|
+
// Centre of 1200×600 canvas
|
|
210
|
+
expect(out[0]).toBeCloseTo(600, -1);
|
|
211
|
+
expect(out[1]).toBeCloseTo(300, -1);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('narrow near/far clip: point at z=-99 is still inside frustum', () => {
|
|
215
|
+
const VP = mat4.create();
|
|
216
|
+
makeViewProj(VP, Math.PI / 3, 800 / 600, 0.1, 100);
|
|
217
|
+
|
|
218
|
+
const out = vec2.create();
|
|
219
|
+
// z=-99: almost at far plane, still in frustum
|
|
220
|
+
const result = worldToScreen(out, [0, 0, -99], VP, 800, 600);
|
|
221
|
+
|
|
222
|
+
expect(result.behind).toBe(false);
|
|
223
|
+
// near far plane → NDC z ≈ 1 (still in [0,1])
|
|
224
|
+
expect(result.onScreen).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('point at z=-0.05 (between camera and near plane) → behind=false, onScreen=false', () => {
|
|
228
|
+
const VP = mat4.create();
|
|
229
|
+
makeViewProj(VP, Math.PI / 3, 800 / 600, 0.1, 100);
|
|
230
|
+
|
|
231
|
+
const out = vec2.create();
|
|
232
|
+
// z=-0.05: between camera and near plane — in front (w>0) but outside frustum
|
|
233
|
+
const result = worldToScreen(out, [0, 0, -0.05], VP, 800, 600);
|
|
234
|
+
|
|
235
|
+
expect(result.behind).toBe(false);
|
|
236
|
+
// NDC z will be outside [0,1] because the point is closer than near plane
|
|
237
|
+
expect(result.onScreen).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('point exactly on near plane (z=-0.1) → onScreen=true', () => {
|
|
241
|
+
const VP = mat4.create();
|
|
242
|
+
makeViewProj(VP, Math.PI / 3, 800 / 600, 0.1, 100);
|
|
243
|
+
|
|
244
|
+
const out = vec2.create();
|
|
245
|
+
const result = worldToScreen(out, [0, 0, -0.1], VP, 800, 600);
|
|
246
|
+
|
|
247
|
+
expect(result.behind).toBe(false);
|
|
248
|
+
expect(result.onScreen).toBe(true);
|
|
249
|
+
// Near plane → NDC z=0 (WebGPU convention)
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ============================================================
|
|
254
|
+
// WorldToScreenResult type test (compile-time only)
|
|
255
|
+
// ============================================================
|
|
256
|
+
|
|
257
|
+
describe('ray.worldToScreen — return type', () => {
|
|
258
|
+
it('returns plain {onScreen, behind} object (no allocation, no Result/ErrorCode)', () => {
|
|
259
|
+
const VP = mat4.create();
|
|
260
|
+
makeViewProj(VP);
|
|
261
|
+
|
|
262
|
+
const out = vec2.create();
|
|
263
|
+
const result = worldToScreen(out, [0, 0, -5], VP, 800, 600);
|
|
264
|
+
|
|
265
|
+
expect(typeof result.onScreen).toBe('boolean');
|
|
266
|
+
expect(typeof result.behind).toBe('boolean');
|
|
267
|
+
// Verify no extra keys
|
|
268
|
+
expect(Object.keys(result).sort()).toEqual(['behind', 'onScreen']);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ============================================================
|
|
273
|
+
// rayTriangleIntersects (feat-20260630-vertex-snapping-picking M1 w1)
|
|
274
|
+
// ============================================================
|
|
275
|
+
// TDD RED phase: these tests are RED until w2 implements Moller-Trumbore.
|
|
276
|
+
//
|
|
277
|
+
// AC-06 coverage:
|
|
278
|
+
// - correct t/u/v for known triangle + ray
|
|
279
|
+
// - degenerate (collinear/zero-area) → hit=false, no NaN propagation
|
|
280
|
+
// - t <= 0 rejection (behind ray origin)
|
|
281
|
+
// - parallel ray → miss
|
|
282
|
+
// - NaN guard on all inputs
|
|
283
|
+
// - barycentric boundary: u<0, v<0, u+v>1 → miss
|
|
284
|
+
// - double-sided: both front and back faces hit
|
|
285
|
+
// - edge/vertex grazing cases (u=0, v=0, u+v=1 boundaries)
|
|
286
|
+
|
|
287
|
+
describe('ray.rayTriangleIntersects — normal hit', () => {
|
|
288
|
+
// A standard triangle in the XY plane at z=-5: right-triangle with vertices
|
|
289
|
+
// (0,0,-5), (2,0,-5), (0,2,-5)
|
|
290
|
+
const A = [0, 0, -5];
|
|
291
|
+
const B = [2, 0, -5];
|
|
292
|
+
const C = [0, 2, -5];
|
|
293
|
+
|
|
294
|
+
it('ray through centre of triangle → hit with correct t,u,v', () => {
|
|
295
|
+
// Ray from (0.5, 0.5, 0) looking down -z: should hit at centre
|
|
296
|
+
const r = [0.5, 0.5, 0, 0, 0, -1] as const;
|
|
297
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
298
|
+
expect(result.hit).toBe(true);
|
|
299
|
+
// t: distance from origin (0.5,0.5,0) to plane z=-5 → t=5
|
|
300
|
+
expect(result.t).toBeCloseTo(5, 5);
|
|
301
|
+
// Barycentric: point (0.5, 0.5) in triangle (0,0)-(2,0)-(0,2)
|
|
302
|
+
// u=(B-A) component, v=(C-A) component
|
|
303
|
+
// P = (1-u-v)*A + u*B + v*C = (2u, 2v)
|
|
304
|
+
// So u=0.25, v=0.25 for (0.5, 0.5)
|
|
305
|
+
expect(result.u).toBeCloseTo(0.25, 5);
|
|
306
|
+
expect(result.v).toBeCloseTo(0.25, 5);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('ray hits vertex A exactly → u=0, v=0', () => {
|
|
310
|
+
const r = [0, 0, 0, 0, 0, -1] as const;
|
|
311
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
312
|
+
expect(result.hit).toBe(true);
|
|
313
|
+
expect(result.u).toBeCloseTo(0, 5);
|
|
314
|
+
expect(result.v).toBeCloseTo(0, 5);
|
|
315
|
+
expect(result.t).toBeCloseTo(5, 5);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('ray hits vertex B exactly → u=1, v=0', () => {
|
|
319
|
+
const r = [2, 0, 0, 0, 0, -1] as const;
|
|
320
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
321
|
+
expect(result.hit).toBe(true);
|
|
322
|
+
expect(result.u).toBeCloseTo(1, 5);
|
|
323
|
+
expect(result.v).toBeCloseTo(0, 5);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('ray hits vertex C exactly → u=0, v=1', () => {
|
|
327
|
+
const r = [0, 2, 0, 0, 0, -1] as const;
|
|
328
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
329
|
+
expect(result.hit).toBe(true);
|
|
330
|
+
expect(result.u).toBeCloseTo(0, 5);
|
|
331
|
+
expect(result.v).toBeCloseTo(1, 5);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('ray hits edge AB midpoint → v=0, u=0.5', () => {
|
|
335
|
+
const r = [1, 0, 0, 0, 0, -1] as const;
|
|
336
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
337
|
+
expect(result.hit).toBe(true);
|
|
338
|
+
expect(result.v).toBeCloseTo(0, 5);
|
|
339
|
+
expect(result.u).toBeCloseTo(0.5, 5);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('ray hits edge AC midpoint → u=0, v=0.5', () => {
|
|
343
|
+
const r = [0, 1, 0, 0, 0, -1] as const;
|
|
344
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
345
|
+
expect(result.hit).toBe(true);
|
|
346
|
+
expect(result.u).toBeCloseTo(0, 5);
|
|
347
|
+
expect(result.v).toBeCloseTo(0.5, 5);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('double-sided: ray from behind (-z → +z) also hits', () => {
|
|
351
|
+
// Triangle at z=-5, ray from z=-10 looking +z
|
|
352
|
+
const r = [0.5, 0.5, -10, 0, 0, 1] as const;
|
|
353
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
354
|
+
expect(result.hit).toBe(true);
|
|
355
|
+
expect(result.t).toBeCloseTo(5, 5);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe('ray.rayTriangleIntersects — miss (outside triangle)', () => {
|
|
360
|
+
const A = [0, 0, -5];
|
|
361
|
+
const B = [2, 0, -5];
|
|
362
|
+
const C = [0, 2, -5];
|
|
363
|
+
|
|
364
|
+
it('u < 0 → miss', () => {
|
|
365
|
+
// Ray at x=-0.5 (left of triangle)
|
|
366
|
+
const r = [-0.5, 0.5, 0, 0, 0, -1] as const;
|
|
367
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
368
|
+
expect(result.hit).toBe(false);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('v < 0 → miss', () => {
|
|
372
|
+
// Ray at y=-0.5 (below triangle)
|
|
373
|
+
const r = [0.5, -0.5, 0, 0, 0, -1] as const;
|
|
374
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
375
|
+
expect(result.hit).toBe(false);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('u + v > 1 → miss', () => {
|
|
379
|
+
// Ray at (1.5, 1.5) which is outside hypotenuse
|
|
380
|
+
const r = [1.5, 1.5, 0, 0, 0, -1] as const;
|
|
381
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
382
|
+
expect(result.hit).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe('ray.rayTriangleIntersects — degenerate triangles', () => {
|
|
387
|
+
it('collinear three points → hit=false', () => {
|
|
388
|
+
const A = [0, 0, 0];
|
|
389
|
+
const B = [1, 1, 1];
|
|
390
|
+
const C = [2, 2, 2]; // all on a line
|
|
391
|
+
const r = [0, 0, -1, 0, 0, 1] as const;
|
|
392
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
393
|
+
expect(result.hit).toBe(false);
|
|
394
|
+
// Verify no NaN propagation
|
|
395
|
+
expect(Number.isNaN(result.t)).toBe(false);
|
|
396
|
+
expect(Number.isNaN(result.u)).toBe(false);
|
|
397
|
+
expect(Number.isNaN(result.v)).toBe(false);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('zero-area triangle (all same point) → hit=false', () => {
|
|
401
|
+
const A = [0, 0, 0];
|
|
402
|
+
const B = [0, 0, 0];
|
|
403
|
+
const C = [0, 0, 0];
|
|
404
|
+
const r = [0, 0, -1, 0, 0, 1] as const;
|
|
405
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
406
|
+
expect(result.hit).toBe(false);
|
|
407
|
+
expect(Number.isNaN(result.t)).toBe(false);
|
|
408
|
+
expect(Number.isNaN(result.u)).toBe(false);
|
|
409
|
+
expect(Number.isNaN(result.v)).toBe(false);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('two identical vertices (zero-area) → hit=false', () => {
|
|
413
|
+
const A = [0, 0, 0];
|
|
414
|
+
const B = [1, 0, 0];
|
|
415
|
+
const C = [1, 0, 0]; // B == C
|
|
416
|
+
const r = [0.3, 0, -1, 0, 0, 1] as const;
|
|
417
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
418
|
+
expect(result.hit).toBe(false);
|
|
419
|
+
expect(Number.isNaN(result.t)).toBe(false);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe('ray.rayTriangleIntersects — t <= 0 rejection', () => {
|
|
424
|
+
it('t < 0 → miss (ray origin behind triangle)', () => {
|
|
425
|
+
const A = [0, 0, 5]; // triangle at z=5
|
|
426
|
+
const B = [2, 0, 5];
|
|
427
|
+
const C = [0, 2, 5];
|
|
428
|
+
// Ray from origin looking -z: triangle is behind the ray
|
|
429
|
+
const r = [0.5, 0.5, 0, 0, 0, -1] as const;
|
|
430
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
431
|
+
expect(result.hit).toBe(false);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('t === 0 → miss (ray origin on triangle)', () => {
|
|
435
|
+
const A = [0, 0, 0];
|
|
436
|
+
const B = [2, 0, 0];
|
|
437
|
+
const C = [0, 2, 0];
|
|
438
|
+
// Ray origin at (0.5, 0.5, 0) — on the triangle plane, inside bounds
|
|
439
|
+
const r = [0.5, 0.5, 0, 0, 0, -1] as const;
|
|
440
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
441
|
+
// t=0 should be rejected per t <= 0 rule
|
|
442
|
+
expect(result.hit).toBe(false);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe('ray.rayTriangleIntersects — parallel ray', () => {
|
|
447
|
+
it('ray parallel to triangle plane → miss', () => {
|
|
448
|
+
const A = [0, 0, -5];
|
|
449
|
+
const B = [2, 0, -5];
|
|
450
|
+
const C = [0, 2, -5];
|
|
451
|
+
// Ray in XY plane (parallel to triangle at z=-5)
|
|
452
|
+
const r = [0.5, 0.5, 0, 1, 0, 0] as const;
|
|
453
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
454
|
+
expect(result.hit).toBe(false);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('ray nearly parallel (extremely shallow angle) → still hits', () => {
|
|
458
|
+
// Large triangle at z=-5, shallow-angle ray from origin
|
|
459
|
+
const A = [0, 0, -5];
|
|
460
|
+
const B = [400, 0, -5];
|
|
461
|
+
const C = [0, 400, -5];
|
|
462
|
+
// Ray from (100,100,0) with dx=0.999, dz=-0.0447 (shallow, ~2.6 deg from XY)
|
|
463
|
+
const r = [100, 100, 0, 0.999, 0, -0.0447] as const;
|
|
464
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
465
|
+
expect(result.hit).toBe(true);
|
|
466
|
+
expect(result.t).toBeGreaterThan(0);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe('ray.rayTriangleIntersects — NaN guard', () => {
|
|
471
|
+
const A = [0, 0, -5];
|
|
472
|
+
const B = [2, 0, -5];
|
|
473
|
+
const C = [0, 2, -5];
|
|
474
|
+
const R = [0.5, 0.5, 0, 0, 0, -1] as const;
|
|
475
|
+
|
|
476
|
+
it('NaN in ray origin → hit=false', () => {
|
|
477
|
+
const r = [NaN, 0.5, 0, 0, 0, -1];
|
|
478
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
479
|
+
expect(result.hit).toBe(false);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('NaN in ray direction → hit=false', () => {
|
|
483
|
+
const r = [0.5, 0.5, 0, NaN, 0, -1];
|
|
484
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
485
|
+
expect(result.hit).toBe(false);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('NaN in triangle vertex → hit=false', () => {
|
|
489
|
+
const badA = [NaN, 0, -5];
|
|
490
|
+
const result = rayTriangleIntersects(R, badA, B, C);
|
|
491
|
+
expect(result.hit).toBe(false);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('NaN in multiple triangle vertices → hit=false', () => {
|
|
495
|
+
const badB = [NaN, NaN, NaN];
|
|
496
|
+
const result = rayTriangleIntersects(R, A, badB, C);
|
|
497
|
+
expect(result.hit).toBe(false);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
describe('ray.rayTriangleIntersects — non-axis-aligned triangle', () => {
|
|
502
|
+
it('arbitrary 3D triangle → correct intersection', () => {
|
|
503
|
+
// Triangle at arbitrary positions
|
|
504
|
+
const A = [1, 2, -5];
|
|
505
|
+
const B = [3, 1, -4];
|
|
506
|
+
const C = [1, 3, -6];
|
|
507
|
+
// Ray from (2, 2, 0) along -z, should hit near centre
|
|
508
|
+
const r = [2, 2, 0, 0, 0, -1] as const;
|
|
509
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
510
|
+
expect(result.hit).toBe(true);
|
|
511
|
+
expect(result.t).toBeGreaterThan(0);
|
|
512
|
+
expect(result.u).toBeGreaterThanOrEqual(0);
|
|
513
|
+
expect(result.v).toBeGreaterThanOrEqual(0);
|
|
514
|
+
expect(result.u + result.v).toBeLessThanOrEqual(1 + 1e-9);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('ray hits triangle from oblique angle', () => {
|
|
518
|
+
const A = [0, 0, -10];
|
|
519
|
+
const B = [2, 0, -10];
|
|
520
|
+
const C = [0, 2, -10];
|
|
521
|
+
// Oblique ray
|
|
522
|
+
const r = [0, 0, 0, 0.1, 0.1, -1] as const;
|
|
523
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
524
|
+
expect(result.hit).toBe(true);
|
|
525
|
+
expect(result.t).toBeGreaterThan(0);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('ray misses non-axis-aligned triangle (near miss)', () => {
|
|
529
|
+
const A = [1, 2, -5];
|
|
530
|
+
const B = [3, 1, -4];
|
|
531
|
+
const C = [1, 3, -6];
|
|
532
|
+
// Ray misses above
|
|
533
|
+
const r = [4, 4, 0, 0, 0, -1] as const;
|
|
534
|
+
const result = rayTriangleIntersects(r, A, B, C);
|
|
535
|
+
expect(result.hit).toBe(false);
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
void expect;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// types.test-d.ts — branded-types mutual-exclusion compile-time assertions (AC-02 / TDD red phase, T-003)
|
|
2
|
+
//
|
|
3
|
+
// Mutual-exclusion check on the seven-piece brand SSOT: every pair of brands is non-substitutable,
|
|
4
|
+
// yet all remain a Float32Array supertype.
|
|
5
|
+
// When src/types.ts is missing or a brand field is duplicated, this file's typecheck must turn red.
|
|
6
|
+
//
|
|
7
|
+
// Related: requirements §AC-02 brand mutual exclusion blocks ≥ 6 cases at compile time;
|
|
8
|
+
// research §Finding 7.3 minimal six-case template;
|
|
9
|
+
// wiki/typescript-branded-types §3.1 expectTypeOf template.
|
|
10
|
+
|
|
11
|
+
import { describe, expectTypeOf, it } from 'vitest';
|
|
12
|
+
import type { Color, Mat3, Mat4, Quat, Vec2, Vec3, Vec4 } from '../types';
|
|
13
|
+
|
|
14
|
+
describe('types — brand mutual exclusion (AC-02 ≥ 6 cases)', () => {
|
|
15
|
+
it('Vec3 ≠ Vec4', () => {
|
|
16
|
+
expectTypeOf<Vec3>().not.toEqualTypeOf<Vec4>();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('Vec3 ≠ Vec2', () => {
|
|
20
|
+
expectTypeOf<Vec3>().not.toEqualTypeOf<Vec2>();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('Vec3 ≠ Quat', () => {
|
|
24
|
+
expectTypeOf<Vec3>().not.toEqualTypeOf<Quat>();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('Vec3 ≠ Mat3', () => {
|
|
28
|
+
expectTypeOf<Vec3>().not.toEqualTypeOf<Mat3>();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('Mat3 ≠ Mat4', () => {
|
|
32
|
+
expectTypeOf<Mat3>().not.toEqualTypeOf<Mat4>();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('Vec4 ≠ Quat (same length 4, distinguished by brand)', () => {
|
|
36
|
+
expectTypeOf<Vec4>().not.toEqualTypeOf<Quat>();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('Quat ≠ Mat4', () => {
|
|
40
|
+
expectTypeOf<Quat>().not.toEqualTypeOf<Mat4>();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('Color ≠ Vec4 (same length 4, semantically distinct)', () => {
|
|
44
|
+
expectTypeOf<Color>().not.toEqualTypeOf<Vec4>();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('types — Float32Array supertype relations', () => {
|
|
49
|
+
it('Vec3 ⊂ Float32Array', () => {
|
|
50
|
+
expectTypeOf<Vec3>().toMatchTypeOf<Float32Array>();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('Mat4 ⊂ Float32Array', () => {
|
|
54
|
+
expectTypeOf<Mat4>().toMatchTypeOf<Float32Array>();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('Float32Array ⊄ Vec3 (a branded type cannot be auto-widened from its base)', () => {
|
|
58
|
+
expectTypeOf<Float32Array>().not.toMatchTypeOf<Vec3>();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('Float32Array ⊄ Mat4 (a branded type cannot be auto-widened from its base)', () => {
|
|
62
|
+
expectTypeOf<Float32Array>().not.toMatchTypeOf<Mat4>();
|
|
63
|
+
});
|
|
64
|
+
});
|