@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
package/src/ray.ts
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
// ray.ts — ray primitive namespace (feat-20260529-picking-raycasting-screen-to-entity M1 / w3 + M2 / w7).
|
|
2
|
+
//
|
|
3
|
+
// Storage layout: Float32Array length 6 [ox, oy, oz, dx, dy, dz], direction normalized.
|
|
4
|
+
// default: origin=(0,0,0), direction=(0,0,-1).
|
|
5
|
+
//
|
|
6
|
+
// Surface: create / getOrigin / getDirection / setOrigin / setDirection /
|
|
7
|
+
// rayAabbIntersects (slab method, 6 degenerate cases) /
|
|
8
|
+
// screenToRay (M2 w7, two-point unproject with WebGPU [0,1] NDC z).
|
|
9
|
+
//
|
|
10
|
+
// Design locks:
|
|
11
|
+
// - branded `Ray` Float32Array local to this module; cast funneled through `create`
|
|
12
|
+
// - pure-function / out-param style consistent with box3 / vec3 / mat4;
|
|
13
|
+
// - `rayAabbIntersects` uses the slab method with inv precomputation,
|
|
14
|
+
// `inv[i] < 0` sign test (not direction test — avoids -0 pitfall),
|
|
15
|
+
// NaN-safe edge/corner handling (Research Finding 2 #5).
|
|
16
|
+
//
|
|
17
|
+
// Related: requirements AC-01 / AC-02 / AC-03;
|
|
18
|
+
// research Finding 2 (slab algorithm + 6 degenerate table);
|
|
19
|
+
// research Finding 6 (WebGPU [0,1] NDC z);
|
|
20
|
+
// plan-tasks.json w3 + w7 acceptanceChecks.
|
|
21
|
+
|
|
22
|
+
import type { Box3Like } from './box3';
|
|
23
|
+
import * as mat4 from './mat4';
|
|
24
|
+
import type { Mat4Like, Vec2, Vec3, Vec3Like } from './types';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Result of a ray-triangle intersection test (Moller-Trumbore).
|
|
28
|
+
* `t` = ray parameter, `u`/`v` = barycentric coordinates.
|
|
29
|
+
*/
|
|
30
|
+
export interface RayTriResult {
|
|
31
|
+
hit: boolean;
|
|
32
|
+
t: number;
|
|
33
|
+
u: number;
|
|
34
|
+
v: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ray storage: Float32Array length 6 [ox, oy, oz, dx, dy, dz], direction normalized.
|
|
39
|
+
* Local brand (not part of the seven-piece SSOT; same rationale as Box3).
|
|
40
|
+
*/
|
|
41
|
+
export type Ray = Float32Array & { readonly __ray: void };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Ray readable input: ArrayLike<number> of length 6 (ordering identical to Ray).
|
|
45
|
+
*/
|
|
46
|
+
export type RayLike = ArrayLike<number>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a Ray. Defaults to origin=(0,0,0), direction=(0,0,-1) normalized.
|
|
50
|
+
*
|
|
51
|
+
* When `out` is not provided, a new Float32Array is allocated.
|
|
52
|
+
* When `origin` / `direction` are provided, direction is normalized
|
|
53
|
+
* (zero-length direction falls back to (0,0,0) per gl-matrix style).
|
|
54
|
+
*/
|
|
55
|
+
export function create(out?: Float32Array, origin?: Vec3Like, direction?: Vec3Like): Ray {
|
|
56
|
+
const r = out ?? new Float32Array(6);
|
|
57
|
+
if (origin) {
|
|
58
|
+
r[0] = origin[0] as number;
|
|
59
|
+
r[1] = origin[1] as number;
|
|
60
|
+
r[2] = origin[2] as number;
|
|
61
|
+
} else {
|
|
62
|
+
r[0] = 0;
|
|
63
|
+
r[1] = 0;
|
|
64
|
+
r[2] = 0;
|
|
65
|
+
}
|
|
66
|
+
if (direction) {
|
|
67
|
+
const dx = direction[0] as number;
|
|
68
|
+
const dy = direction[1] as number;
|
|
69
|
+
const dz = direction[2] as number;
|
|
70
|
+
const lenSq = dx * dx + dy * dy + dz * dz;
|
|
71
|
+
if (lenSq < 1e-12) {
|
|
72
|
+
r[3] = 0;
|
|
73
|
+
r[4] = 0;
|
|
74
|
+
r[5] = 0;
|
|
75
|
+
} else {
|
|
76
|
+
const inv = 1 / Math.sqrt(lenSq);
|
|
77
|
+
r[3] = dx * inv;
|
|
78
|
+
r[4] = dy * inv;
|
|
79
|
+
r[5] = dz * inv;
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
r[3] = 0;
|
|
83
|
+
r[4] = 0;
|
|
84
|
+
r[5] = -1;
|
|
85
|
+
}
|
|
86
|
+
return r as Ray;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Copy the origin of `r` into `out`. Returns `out`.
|
|
91
|
+
*/
|
|
92
|
+
export function getOrigin(out: Vec3, r: RayLike): Vec3 {
|
|
93
|
+
out[0] = r[0] as number;
|
|
94
|
+
out[1] = r[1] as number;
|
|
95
|
+
out[2] = r[2] as number;
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Copy the direction of `r` into `out`. Returns `out`.
|
|
101
|
+
*/
|
|
102
|
+
export function getDirection(out: Vec3, r: RayLike): Vec3 {
|
|
103
|
+
out[0] = r[3] as number;
|
|
104
|
+
out[1] = r[4] as number;
|
|
105
|
+
out[2] = r[5] as number;
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Overwrite the origin of `r` with `o`. Returns `r`.
|
|
111
|
+
*/
|
|
112
|
+
export function setOrigin(r: Ray, o: Vec3Like): Ray {
|
|
113
|
+
r[0] = o[0] as number;
|
|
114
|
+
r[1] = o[1] as number;
|
|
115
|
+
r[2] = o[2] as number;
|
|
116
|
+
return r;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Overwrite the direction of `r` with `d` and normalize it.
|
|
121
|
+
* Zero-length direction falls back to (0,0,0) (gl-matrix style).
|
|
122
|
+
* Returns `r`.
|
|
123
|
+
*/
|
|
124
|
+
export function setDirection(r: Ray, d: Vec3Like): Ray {
|
|
125
|
+
const dx = d[0] as number;
|
|
126
|
+
const dy = d[1] as number;
|
|
127
|
+
const dz = d[2] as number;
|
|
128
|
+
const lenSq = dx * dx + dy * dy + dz * dz;
|
|
129
|
+
if (lenSq < 1e-12) {
|
|
130
|
+
r[3] = 0;
|
|
131
|
+
r[4] = 0;
|
|
132
|
+
r[5] = 0;
|
|
133
|
+
} else {
|
|
134
|
+
const inv = 1 / Math.sqrt(lenSq);
|
|
135
|
+
r[3] = dx * inv;
|
|
136
|
+
r[4] = dy * inv;
|
|
137
|
+
r[5] = dz * inv;
|
|
138
|
+
}
|
|
139
|
+
return r;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Result of a ray-AABB intersection test.
|
|
144
|
+
*/
|
|
145
|
+
export interface RayAabbResult {
|
|
146
|
+
hit: boolean;
|
|
147
|
+
/** Entry distance along the ray (clamped to 0 when origin is inside the box). */
|
|
148
|
+
tmin: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Test whether a ray intersects an axis-aligned bounding box using the
|
|
153
|
+
* slab (Kay-Kajiya) method.
|
|
154
|
+
*
|
|
155
|
+
* Six degenerate cases per research Finding 2:
|
|
156
|
+
* 1. hit from outside → `tmin > 0`
|
|
157
|
+
* 2. hit from inside → `tmin = 0`
|
|
158
|
+
* 3. miss (ray away) → `hit = false`
|
|
159
|
+
* 4. parallel-axis miss → natural via ±Inf propagation
|
|
160
|
+
* 5. edge/corner NaN-safe → guard `t1 > t2` NaN scenarios with `Number.isNaN`
|
|
161
|
+
* 6. thin box (1D/2D) → correct via interval shrink
|
|
162
|
+
*
|
|
163
|
+
* Algorithm: precompute `inv = 1/D`, use `inv[i] < 0` (not direction[i] < 0)
|
|
164
|
+
* to handle `-0` direction correctly. On each axis compute `(min - O)*inv` and
|
|
165
|
+
* `(max - O)*inv`, swap when `inv < 0`, then accumulate `t_near = max(t_near, t1)`,
|
|
166
|
+
* `t_far = min(t_far, t2)`. Hit when `t_far >= max(t_near, 0)`.
|
|
167
|
+
*
|
|
168
|
+
* Input: `r` (Ray with normalized direction) + `aabb` (Box3Like, 6 floats).
|
|
169
|
+
* Output: `{ hit: boolean, tmin: number }`.
|
|
170
|
+
*/
|
|
171
|
+
export function rayAabbIntersects(r: RayLike, aabb: Box3Like): RayAabbResult {
|
|
172
|
+
const ox = r[0] as number;
|
|
173
|
+
const oy = r[1] as number;
|
|
174
|
+
const oz = r[2] as number;
|
|
175
|
+
const dx = r[3] as number;
|
|
176
|
+
const dy = r[4] as number;
|
|
177
|
+
const dz = r[5] as number;
|
|
178
|
+
|
|
179
|
+
const minX = aabb[0] as number;
|
|
180
|
+
const minY = aabb[1] as number;
|
|
181
|
+
const minZ = aabb[2] as number;
|
|
182
|
+
const maxX = aabb[3] as number;
|
|
183
|
+
const maxY = aabb[4] as number;
|
|
184
|
+
const maxZ = aabb[5] as number;
|
|
185
|
+
|
|
186
|
+
// precomputed inverse (Research Finding 2 #2: IEEE 754 ±Inf for D_i = 0)
|
|
187
|
+
const invX = 1 / dx;
|
|
188
|
+
const invY = 1 / dy;
|
|
189
|
+
const invZ = 1 / dz;
|
|
190
|
+
|
|
191
|
+
// X axis
|
|
192
|
+
let t1x = (minX - ox) * invX;
|
|
193
|
+
let t2x = (maxX - ox) * invX;
|
|
194
|
+
// sign test on inv, not direction — avoids -0 pitfall (Finding 2 #3)
|
|
195
|
+
if (invX < 0) {
|
|
196
|
+
const tmp = t1x;
|
|
197
|
+
t1x = t2x;
|
|
198
|
+
t2x = tmp;
|
|
199
|
+
}
|
|
200
|
+
// NaN-safe: when t1x or t2x is NaN (0*∞ from origin on face), skip this axis
|
|
201
|
+
// for tnear accumulation (interval unbounded on this axis)
|
|
202
|
+
let tnear = Number.isNaN(t1x) ? -Infinity : t1x;
|
|
203
|
+
let tfar = Number.isNaN(t2x) ? Infinity : t2x;
|
|
204
|
+
|
|
205
|
+
// Y axis
|
|
206
|
+
let t1y = (minY - oy) * invY;
|
|
207
|
+
let t2y = (maxY - oy) * invY;
|
|
208
|
+
if (invY < 0) {
|
|
209
|
+
const tmp = t1y;
|
|
210
|
+
t1y = t2y;
|
|
211
|
+
t2y = tmp;
|
|
212
|
+
}
|
|
213
|
+
if (!Number.isNaN(t1y) && t1y > tnear) tnear = t1y;
|
|
214
|
+
if (!Number.isNaN(t2y) && t2y < tfar) tfar = t2y;
|
|
215
|
+
|
|
216
|
+
// early out: interval already invalid
|
|
217
|
+
if (tnear > tfar) return { hit: false, tmin: 0 };
|
|
218
|
+
|
|
219
|
+
// Z axis
|
|
220
|
+
let t1z = (minZ - oz) * invZ;
|
|
221
|
+
let t2z = (maxZ - oz) * invZ;
|
|
222
|
+
if (invZ < 0) {
|
|
223
|
+
const tmp = t1z;
|
|
224
|
+
t1z = t2z;
|
|
225
|
+
t2z = tmp;
|
|
226
|
+
}
|
|
227
|
+
if (!Number.isNaN(t1z) && t1z > tnear) tnear = t1z;
|
|
228
|
+
if (!Number.isNaN(t2z) && t2z < tfar) tfar = t2z;
|
|
229
|
+
|
|
230
|
+
// Kay-Kajiya hit criterion: tfar >= max(tnear, 0) and tfar >= 0
|
|
231
|
+
if (tfar >= 0 && tfar >= tnear) {
|
|
232
|
+
return { hit: true, tmin: tnear > 0 ? tnear : 0 };
|
|
233
|
+
}
|
|
234
|
+
return { hit: false, tmin: 0 };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ============================================================
|
|
238
|
+
// screenToRay (feat-20260529-picking-raycasting-screen-to-entity M2 w7)
|
|
239
|
+
// ============================================================
|
|
240
|
+
//
|
|
241
|
+
// Two-point unproject method: unproject near (z=0) and far (z=1) NDC points
|
|
242
|
+
// through invVP, then ray.origin = nearWorld, ray.direction = normalize(farWorld - nearWorld).
|
|
243
|
+
//
|
|
244
|
+
// WebGPU [0,1] NDC z convention (D-NDC / research Finding 6):
|
|
245
|
+
// near plane → ndc_z = 0, far plane → ndc_z = 1.
|
|
246
|
+
//
|
|
247
|
+
// y-flip: ndc_y = 1 - 2*screenY/viewportH (screen y-down → NDC y-up).
|
|
248
|
+
//
|
|
249
|
+
// Input sanitization: clamp screen coords to [0, vpW/vpH]; NaN/Inf inputs
|
|
250
|
+
// fall back to centre (0,0,0,0,0,-1) to guarantee a defined Ray.
|
|
251
|
+
//
|
|
252
|
+
// Related: plan-tasks.json w7; requirements in-scope #3 + edge cases;
|
|
253
|
+
// research Finding 6 (WebGPU z) + Finding 3 (y-flip formula).
|
|
254
|
+
|
|
255
|
+
const _nearNdC = new Float32Array(3);
|
|
256
|
+
const _farNdC = new Float32Array(3);
|
|
257
|
+
const _nearWorld = new Float32Array(3);
|
|
258
|
+
const _farWorld = new Float32Array(3);
|
|
259
|
+
const _tmpInvVP = new Float32Array(16);
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Project a screen-space coordinate into a world-space Ray.
|
|
263
|
+
*
|
|
264
|
+
* `screenX` / `screenY` are in pixels relative to the top-left corner of the viewport
|
|
265
|
+
* (DOM convention: y-down). They are clamped to `[0, vpWidth]` / `[0, vpHeight]` and
|
|
266
|
+
* sanitized against NaN/Inf.
|
|
267
|
+
*
|
|
268
|
+
* `kind` discriminates the projection type so callers can branch on the result if needed;
|
|
269
|
+
* both paths use the same two-point unproject algorithm.
|
|
270
|
+
*
|
|
271
|
+
* @param out The Ray to write into.
|
|
272
|
+
* @param screenX Horizontal pixel coordinate (y-down origin, top-left).
|
|
273
|
+
* @param screenY Vertical pixel coordinate.
|
|
274
|
+
* @param vpWidth Viewport width in pixels.
|
|
275
|
+
* @param vpHeight Viewport height in pixels.
|
|
276
|
+
* @param view Camera view matrix (world→eye).
|
|
277
|
+
* @param proj Camera projection matrix (eye→clip).
|
|
278
|
+
* @param kind `'perspective'` or `'orthographic'`.
|
|
279
|
+
* @returns `out` (same Ray instance).
|
|
280
|
+
*/
|
|
281
|
+
export function screenToRay(
|
|
282
|
+
out: Ray,
|
|
283
|
+
screenX: number,
|
|
284
|
+
screenY: number,
|
|
285
|
+
vpWidth: number,
|
|
286
|
+
vpHeight: number,
|
|
287
|
+
view: Mat4Like,
|
|
288
|
+
proj: Mat4Like,
|
|
289
|
+
_kind: 'perspective' | 'orthographic',
|
|
290
|
+
): Ray {
|
|
291
|
+
// --- input sanitization ---
|
|
292
|
+
if (
|
|
293
|
+
!Number.isFinite(screenX) ||
|
|
294
|
+
!Number.isFinite(screenY) ||
|
|
295
|
+
!Number.isFinite(vpWidth) ||
|
|
296
|
+
!Number.isFinite(vpHeight) ||
|
|
297
|
+
vpWidth <= 0 ||
|
|
298
|
+
vpHeight <= 0
|
|
299
|
+
) {
|
|
300
|
+
// Degenerate viewport or non-finite input: return a safe centre ray
|
|
301
|
+
out[0] = 0;
|
|
302
|
+
out[1] = 0;
|
|
303
|
+
out[2] = 0;
|
|
304
|
+
out[3] = 0;
|
|
305
|
+
out[4] = 0;
|
|
306
|
+
out[5] = -1;
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Clamp to viewport bounds
|
|
311
|
+
const sx = screenX < 0 ? 0 : screenX > vpWidth ? vpWidth : screenX;
|
|
312
|
+
const sy = screenY < 0 ? 0 : screenY > vpHeight ? vpHeight : screenY;
|
|
313
|
+
|
|
314
|
+
// --- screen → NDC (y-flip) ---
|
|
315
|
+
const ndcX = (2 * sx) / vpWidth - 1;
|
|
316
|
+
const ndcY = 1 - (2 * sy) / vpHeight;
|
|
317
|
+
|
|
318
|
+
// --- build invVP = invert(proj * view) ---
|
|
319
|
+
mat4.multiply(_tmpInvVP as unknown as import('./types').Mat4, proj, view);
|
|
320
|
+
mat4.invert(_tmpInvVP as unknown as import('./types').Mat4, _tmpInvVP);
|
|
321
|
+
|
|
322
|
+
// --- unproject near (z=0) and far (z=1) ---
|
|
323
|
+
_nearNdC[0] = ndcX;
|
|
324
|
+
_nearNdC[1] = ndcY;
|
|
325
|
+
_nearNdC[2] = 0;
|
|
326
|
+
|
|
327
|
+
_farNdC[0] = ndcX;
|
|
328
|
+
_farNdC[1] = ndcY;
|
|
329
|
+
_farNdC[2] = 1;
|
|
330
|
+
|
|
331
|
+
mat4.unproject(_nearWorld as unknown as Vec3, _nearNdC, _tmpInvVP as unknown as Mat4Like);
|
|
332
|
+
mat4.unproject(_farWorld as unknown as Vec3, _farNdC, _tmpInvVP as unknown as Mat4Like);
|
|
333
|
+
|
|
334
|
+
// --- write origin = nearWorld ---
|
|
335
|
+
out[0] = _nearWorld[0] as number;
|
|
336
|
+
out[1] = _nearWorld[1] as number;
|
|
337
|
+
out[2] = _nearWorld[2] as number;
|
|
338
|
+
|
|
339
|
+
// --- compute direction = normalize(far - near) ---
|
|
340
|
+
const dx = (_farWorld[0] as number) - (_nearWorld[0] as number);
|
|
341
|
+
const dy = (_farWorld[1] as number) - (_nearWorld[1] as number);
|
|
342
|
+
const dz = (_farWorld[2] as number) - (_nearWorld[2] as number);
|
|
343
|
+
const lenSq = dx * dx + dy * dy + dz * dz;
|
|
344
|
+
if (lenSq < 1e-12) {
|
|
345
|
+
// Degenerate: far == near (e.g. infinite-far projection, zero-depth frustum)
|
|
346
|
+
// Fall back to camera forward direction
|
|
347
|
+
out[3] = 0;
|
|
348
|
+
out[4] = 0;
|
|
349
|
+
out[5] = -1;
|
|
350
|
+
} else {
|
|
351
|
+
const inv = 1 / Math.sqrt(lenSq);
|
|
352
|
+
out[3] = dx * inv;
|
|
353
|
+
out[4] = dy * inv;
|
|
354
|
+
out[5] = dz * inv;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ============================================================
|
|
361
|
+
// worldToScreen (feat-20260617-host-engine-contract-and-video-cutscene M2 w5)
|
|
362
|
+
// ============================================================
|
|
363
|
+
//
|
|
364
|
+
// Self-contained mat4 * vec4 to get pre-divide w (do NOT wrap projectPoint — it discards w,
|
|
365
|
+
// research Finding 3). behind = w<0; onScreen = NDC xyz all in [-1,1] for xy and [0,1] for z.
|
|
366
|
+
// Pixel map y-down top-left: px=(ndc.x*0.5+0.5)*w, py=(1-(ndc.y*0.5+0.5))*h.
|
|
367
|
+
// out-param writes Vec2; returns plain {onScreen, behind} flags (no allocation).
|
|
368
|
+
//
|
|
369
|
+
// Related: requirements AC-01; plan-strategy D-2; research Finding 3/5/6.
|
|
370
|
+
|
|
371
|
+
/** Result of projecting a world-space point to screen-space pixel coordinates. */
|
|
372
|
+
export interface WorldToScreenResult {
|
|
373
|
+
/** True if the NDC coordinates are within clip-space bounds. */
|
|
374
|
+
onScreen: boolean;
|
|
375
|
+
/** True if the original world-space point is behind the camera (w < 0).
|
|
376
|
+
* When true, `out` is meaningless (requirements §7). */
|
|
377
|
+
behind: boolean;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Project a world-space point to screen-space pixel coordinates.
|
|
382
|
+
*
|
|
383
|
+
* Performs a full mat4 * vec4 internally to capture the pre-divide `w` component
|
|
384
|
+
* (projectPoint discards `w`, so it cannot report `behind`).
|
|
385
|
+
*
|
|
386
|
+
* Pixel mapping: top-left origin, y-down (DOM convention).
|
|
387
|
+
* px = (ndc.x * 0.5 + 0.5) * canvasW
|
|
388
|
+
* py = (1 - (ndc.y * 0.5 + 0.5)) * canvasH
|
|
389
|
+
*
|
|
390
|
+
* Degenerate viewport (canvasW <= 0 || canvasH <= 0): returns { onScreen: false, behind: false },
|
|
391
|
+
* leaves `out` untouched.
|
|
392
|
+
*
|
|
393
|
+
* @param out Vec2 to write pixel coordinates into (y-down, top-left origin).
|
|
394
|
+
* @param worldPos World-space point (3 floats).
|
|
395
|
+
* @param viewProj Combined view-projection matrix (proj * view, 16 floats).
|
|
396
|
+
* @param canvasW Canvas width in pixels.
|
|
397
|
+
* @param canvasH Canvas height in pixels.
|
|
398
|
+
* @returns { onScreen: boolean, behind: boolean } — pure data flags, no allocation.
|
|
399
|
+
*/
|
|
400
|
+
export function worldToScreen(
|
|
401
|
+
out: Vec2,
|
|
402
|
+
worldPos: Vec3Like,
|
|
403
|
+
viewProj: Mat4Like,
|
|
404
|
+
canvasW: number,
|
|
405
|
+
canvasH: number,
|
|
406
|
+
): WorldToScreenResult {
|
|
407
|
+
// Degenerate viewport: leave `out` untouched, report off-screen.
|
|
408
|
+
if (!(canvasW > 0) || !(canvasH > 0)) {
|
|
409
|
+
return { onScreen: false, behind: false };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const x = worldPos[0] as number;
|
|
413
|
+
const y = worldPos[1] as number;
|
|
414
|
+
const z = worldPos[2] as number;
|
|
415
|
+
|
|
416
|
+
// clip = viewProj * [x, y, z, 1] (column-major: m[col*4 + row])
|
|
417
|
+
const clipX =
|
|
418
|
+
(viewProj[0] as number) * x +
|
|
419
|
+
(viewProj[4] as number) * y +
|
|
420
|
+
(viewProj[8] as number) * z +
|
|
421
|
+
(viewProj[12] as number);
|
|
422
|
+
const clipY =
|
|
423
|
+
(viewProj[1] as number) * x +
|
|
424
|
+
(viewProj[5] as number) * y +
|
|
425
|
+
(viewProj[9] as number) * z +
|
|
426
|
+
(viewProj[13] as number);
|
|
427
|
+
const clipZ =
|
|
428
|
+
(viewProj[2] as number) * x +
|
|
429
|
+
(viewProj[6] as number) * y +
|
|
430
|
+
(viewProj[10] as number) * z +
|
|
431
|
+
(viewProj[14] as number);
|
|
432
|
+
const clipW =
|
|
433
|
+
(viewProj[3] as number) * x +
|
|
434
|
+
(viewProj[7] as number) * y +
|
|
435
|
+
(viewProj[11] as number) * z +
|
|
436
|
+
(viewProj[15] as number);
|
|
437
|
+
|
|
438
|
+
// behind = camera-space point is behind the eye (w < 0); `out` is then meaningless.
|
|
439
|
+
if (clipW < 0) {
|
|
440
|
+
return { onScreen: false, behind: true };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const invW = 1 / clipW;
|
|
444
|
+
const ndcX = clipX * invW;
|
|
445
|
+
const ndcY = clipY * invW;
|
|
446
|
+
const ndcZ = clipZ * invW;
|
|
447
|
+
|
|
448
|
+
// y-down pixel map, top-left origin (DOM convention).
|
|
449
|
+
out[0] = (ndcX * 0.5 + 0.5) * canvasW;
|
|
450
|
+
out[1] = (1 - (ndcY * 0.5 + 0.5)) * canvasH;
|
|
451
|
+
|
|
452
|
+
// onScreen: NDC xy in [-1, 1], NDC z in [0, 1] (WebGPU clip-space z convention).
|
|
453
|
+
const onScreen = ndcX >= -1 && ndcX <= 1 && ndcY >= -1 && ndcY <= 1 && ndcZ >= 0 && ndcZ <= 1;
|
|
454
|
+
|
|
455
|
+
return { onScreen, behind: false };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ============================================================
|
|
459
|
+
// rayTriangleIntersects (feat-20260630-vertex-snapping-picking M1)
|
|
460
|
+
// ============================================================
|
|
461
|
+
//
|
|
462
|
+
// Moller-Trumbore algorithm. Returns RayTriResult { hit, t, u, v }.
|
|
463
|
+
// Double-sided: uses abs(det) < epsilon (no backface culling).
|
|
464
|
+
// Degenerate (collinear/zero-area/parallel) triangles return hit=false to avoid NaN propagation.
|
|
465
|
+
// t <= 0 rejected (behind ray origin).
|
|
466
|
+
// Barycentric boundary: u >= 0, v >= 0, u + v <= 1.
|
|
467
|
+
// NaN guard on all inputs.
|
|
468
|
+
//
|
|
469
|
+
// Related: requirements AC-06; plan-strategy D-1;
|
|
470
|
+
// KB 2026-06-30-scratchapixel-moller-trumbore.md;
|
|
471
|
+
// plan-tasks.json w2 acceptanceCheck.
|
|
472
|
+
|
|
473
|
+
const RAY_TRI_EPSILON = 1e-8;
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Test whether a ray intersects a triangle using the Moller-Trumbore algorithm.
|
|
477
|
+
*
|
|
478
|
+
* Double-sided: both front and back faces are checked (abs(det) < epsilon).
|
|
479
|
+
* Degenerate triangles (collinear / zero-area) return hit=false.
|
|
480
|
+
* Ray origin behind the triangle (t <= 0) is rejected.
|
|
481
|
+
* NaN inputs are guarded against — any NaN input returns hit=false.
|
|
482
|
+
*
|
|
483
|
+
* @param r Ray (RayLike, 6 floats: ox,oy,oz,dx,dy,dz with normalized direction).
|
|
484
|
+
* @param a Triangle vertex A (Vec3Like, 3 floats).
|
|
485
|
+
* @param b Triangle vertex B (Vec3Like, 3 floats).
|
|
486
|
+
* @param c Triangle vertex C (Vec3Like, 3 floats).
|
|
487
|
+
* @returns RayTriResult with hit flag, t (ray parameter), and u/v (barycentric coordinates).
|
|
488
|
+
*/
|
|
489
|
+
export function rayTriangleIntersects(
|
|
490
|
+
r: RayLike,
|
|
491
|
+
a: Vec3Like,
|
|
492
|
+
b: Vec3Like,
|
|
493
|
+
c: Vec3Like,
|
|
494
|
+
): RayTriResult {
|
|
495
|
+
// NaN guard: any NaN input → miss (prevents NaN propagation through the algorithm)
|
|
496
|
+
if (
|
|
497
|
+
Number.isNaN(r[0] as number) ||
|
|
498
|
+
Number.isNaN(r[1] as number) ||
|
|
499
|
+
Number.isNaN(r[2] as number) ||
|
|
500
|
+
Number.isNaN(r[3] as number) ||
|
|
501
|
+
Number.isNaN(r[4] as number) ||
|
|
502
|
+
Number.isNaN(r[5] as number) ||
|
|
503
|
+
Number.isNaN(a[0] as number) ||
|
|
504
|
+
Number.isNaN(a[1] as number) ||
|
|
505
|
+
Number.isNaN(a[2] as number) ||
|
|
506
|
+
Number.isNaN(b[0] as number) ||
|
|
507
|
+
Number.isNaN(b[1] as number) ||
|
|
508
|
+
Number.isNaN(b[2] as number) ||
|
|
509
|
+
Number.isNaN(c[0] as number) ||
|
|
510
|
+
Number.isNaN(c[1] as number) ||
|
|
511
|
+
Number.isNaN(c[2] as number)
|
|
512
|
+
) {
|
|
513
|
+
return { hit: false, t: 0, u: 0, v: 0 };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const ox = r[0] as number;
|
|
517
|
+
const oy = r[1] as number;
|
|
518
|
+
const oz = r[2] as number;
|
|
519
|
+
const dx = r[3] as number;
|
|
520
|
+
const dy = r[4] as number;
|
|
521
|
+
const dz = r[5] as number;
|
|
522
|
+
|
|
523
|
+
// E1 = B - A, E2 = C - A
|
|
524
|
+
const e1x = (b[0] as number) - (a[0] as number);
|
|
525
|
+
const e1y = (b[1] as number) - (a[1] as number);
|
|
526
|
+
const e1z = (b[2] as number) - (a[2] as number);
|
|
527
|
+
const e2x = (c[0] as number) - (a[0] as number);
|
|
528
|
+
const e2y = (c[1] as number) - (a[1] as number);
|
|
529
|
+
const e2z = (c[2] as number) - (a[2] as number);
|
|
530
|
+
|
|
531
|
+
// P = D x E2
|
|
532
|
+
const px = dy * e2z - dz * e2y;
|
|
533
|
+
const py = dz * e2x - dx * e2z;
|
|
534
|
+
const pz = dx * e2y - dy * e2x;
|
|
535
|
+
|
|
536
|
+
// det = E1 . P
|
|
537
|
+
const det = e1x * px + e1y * py + e1z * pz;
|
|
538
|
+
|
|
539
|
+
// Double-sided: abs(det) < epsilon → parallel / degenerate → miss
|
|
540
|
+
if (Math.abs(det) < RAY_TRI_EPSILON) {
|
|
541
|
+
return { hit: false, t: 0, u: 0, v: 0 };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const invDet = 1 / det;
|
|
545
|
+
|
|
546
|
+
// T = O - A
|
|
547
|
+
const tx = ox - (a[0] as number);
|
|
548
|
+
const ty = oy - (a[1] as number);
|
|
549
|
+
const tz = oz - (a[2] as number);
|
|
550
|
+
|
|
551
|
+
// u = (T . P) * invDet
|
|
552
|
+
const u = (tx * px + ty * py + tz * pz) * invDet;
|
|
553
|
+
if (u < 0 || u > 1) {
|
|
554
|
+
return { hit: false, t: 0, u, v: 0 };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Q = T x E1
|
|
558
|
+
const qx = ty * e1z - tz * e1y;
|
|
559
|
+
const qy = tz * e1x - tx * e1z;
|
|
560
|
+
const qz = tx * e1y - ty * e1x;
|
|
561
|
+
|
|
562
|
+
// v = (D . Q) * invDet
|
|
563
|
+
const v = (dx * qx + dy * qy + dz * qz) * invDet;
|
|
564
|
+
if (v < 0 || u + v > 1) {
|
|
565
|
+
return { hit: false, t: 0, u, v };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// t = (E2 . Q) * invDet
|
|
569
|
+
const t = (e2x * qx + e2y * qy + e2z * qz) * invDet;
|
|
570
|
+
if (t <= 0) {
|
|
571
|
+
return { hit: false, t, u, v };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return { hit: true, t, u, v };
|
|
575
|
+
}
|