@forgeax/engine-math 0.1.2

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,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
+ });