@forgeax/engine-picking 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.
- package/LICENSE +202 -0
- package/README.md +168 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/glyph-text-pick.test.d.ts +2 -0
- package/dist/__tests__/glyph-text-pick.test.d.ts.map +1 -0
- package/dist/__tests__/pick-errors.test-d.d.ts +2 -0
- package/dist/__tests__/pick-errors.test-d.d.ts.map +1 -0
- package/dist/__tests__/pick-errors.test.d.ts +2 -0
- package/dist/__tests__/pick-errors.test.d.ts.map +1 -0
- package/dist/__tests__/pick-tile.test.d.ts +2 -0
- package/dist/__tests__/pick-tile.test.d.ts.map +1 -0
- package/dist/__tests__/pick-vertex.unit.test.d.ts +2 -0
- package/dist/__tests__/pick-vertex.unit.test.d.ts.map +1 -0
- package/dist/__tests__/pick.test.d.ts +2 -0
- package/dist/__tests__/pick.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-regression.integration.test.d.ts +2 -0
- package/dist/__tests__/visibility-regression.integration.test.d.ts.map +1 -0
- package/dist/asset-port.d.ts +6 -0
- package/dist/asset-port.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +444 -0
- package/dist/index.mjs.map +1 -0
- package/dist/pick-core.d.ts +39 -0
- package/dist/pick-core.d.ts.map +1 -0
- package/dist/pick-errors.d.ts +39 -0
- package/dist/pick-errors.d.ts.map +1 -0
- package/dist/pick-tile.d.ts +44 -0
- package/dist/pick-tile.d.ts.map +1 -0
- package/dist/pick-vertex.d.ts +87 -0
- package/dist/pick-vertex.d.ts.map +1 -0
- package/dist/pick.d.ts +34 -0
- package/dist/pick.d.ts.map +1 -0
- package/dist/viewport-to-world.d.ts +12 -0
- package/dist/viewport-to-world.d.ts.map +1 -0
- package/package.json +66 -0
- package/src/__tests__/glyph-text-pick.test.ts +131 -0
- package/src/__tests__/pick-errors.test-d.ts +66 -0
- package/src/__tests__/pick-errors.test.ts +71 -0
- package/src/__tests__/pick-tile.test.ts +239 -0
- package/src/__tests__/pick-vertex.unit.test.ts +1588 -0
- package/src/__tests__/pick.test.ts +525 -0
- package/src/__tests__/visibility-regression.integration.test.ts +74 -0
- package/src/asset-port.ts +13 -0
- package/src/index.ts +21 -0
- package/src/pick-core.ts +114 -0
- package/src/pick-errors.ts +69 -0
- package/src/pick-tile.ts +157 -0
- package/src/pick-vertex.ts +593 -0
- package/src/pick.ts +148 -0
- package/src/viewport-to-world.ts +23 -0
|
@@ -0,0 +1,1588 @@
|
|
|
1
|
+
// pick-vertex.unit.test.ts — feat-20260630-vertex-snapping-picking M2 w3 + w4 + M3 w6 + w7.
|
|
2
|
+
// biome-ignore-all lint/style/noNonNullAssertion: test assertions use ! after expect() guards
|
|
3
|
+
//
|
|
4
|
+
// TDD red phase (w3): core behavior tests for pickVertexOnEntity covering
|
|
5
|
+
// AC-01/AC-03/AC-04/AC-05/AC-08/AC-10 + D-9 propagate preamble.
|
|
6
|
+
//
|
|
7
|
+
// TDD red phase (w4): AC-13 narrowing type-check block verifying that
|
|
8
|
+
// pickVertexOnEntity's overload signatures enable static narrowing (no `as` cast).
|
|
9
|
+
//
|
|
10
|
+
// TDD red phase (w6): degradation input tests covering AC-09 (strip/u16/undefined
|
|
11
|
+
// position/empty/NaN) + AC-07 (builtin no-AABB fallback) + R-3 (behind-camera).
|
|
12
|
+
//
|
|
13
|
+
// TDD red phase (w7): pickVertex full-scene tests covering AC-02 (three-state
|
|
14
|
+
// return) + R-2 (AABB coarse cull multi-entity) + limit > available vertices.
|
|
15
|
+
|
|
16
|
+
import { type EntityHandle, World } from '@forgeax/engine-ecs';
|
|
17
|
+
import { createPrimitiveMesh } from '@forgeax/engine-geometry';
|
|
18
|
+
import { mat4, ray, type Vec3Like, vec2, vec3 } from '@forgeax/engine-math';
|
|
19
|
+
import {
|
|
20
|
+
CAMERA_PROJECTION_PERSPECTIVE,
|
|
21
|
+
Camera,
|
|
22
|
+
cameraProjectionFromF32,
|
|
23
|
+
Materials,
|
|
24
|
+
MeshFilter,
|
|
25
|
+
MeshRenderer,
|
|
26
|
+
} from '@forgeax/engine-render';
|
|
27
|
+
import { propagateTransforms, Transform } from '@forgeax/engine-scene';
|
|
28
|
+
|
|
29
|
+
import type { Handle, MeshAsset, VertexAttributeMap } from '@forgeax/engine-types';
|
|
30
|
+
import { describe, expect, it } from 'vitest';
|
|
31
|
+
import { resolvePickingAsset } from '../asset-port';
|
|
32
|
+
import { PickError } from '../pick-errors';
|
|
33
|
+
import { pickVertex, pickVertexOnEntity, type VertexHit } from '../pick-vertex';
|
|
34
|
+
|
|
35
|
+
// ── shared constants ─────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const VP = 600; // square viewport so screen-centre maps to the -Z axis ray
|
|
38
|
+
|
|
39
|
+
// ── helpers ─────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function readWorldMatrix(world: World, entity: EntityHandle): Float32Array | undefined {
|
|
42
|
+
const result = world.get(entity, Transform);
|
|
43
|
+
if (!result.ok) return undefined;
|
|
44
|
+
return new Float32Array(result.value.world);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function translateTransform(x: number, y: number, z: number) {
|
|
48
|
+
return {
|
|
49
|
+
pos: [x, y, z],
|
|
50
|
+
quat: [0, 0, 0, 1],
|
|
51
|
+
scale: [1, 1, 1],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function aabbFromPositions(positions: ArrayLike<number>): Float32Array {
|
|
56
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
57
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
58
|
+
let minZ = Number.POSITIVE_INFINITY;
|
|
59
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
60
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
61
|
+
let maxZ = Number.NEGATIVE_INFINITY;
|
|
62
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
63
|
+
minX = Math.min(minX, positions[i] as number);
|
|
64
|
+
minY = Math.min(minY, positions[i + 1] as number);
|
|
65
|
+
minZ = Math.min(minZ, positions[i + 2] as number);
|
|
66
|
+
maxX = Math.max(maxX, positions[i] as number);
|
|
67
|
+
maxY = Math.max(maxY, positions[i + 1] as number);
|
|
68
|
+
maxZ = Math.max(maxZ, positions[i + 2] as number);
|
|
69
|
+
}
|
|
70
|
+
return new Float32Array([minX, minY, minZ, maxX, maxY, maxZ]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface Scene {
|
|
74
|
+
world: World;
|
|
75
|
+
material: Handle<'MaterialAsset', 'shared'>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function makeScene(): Scene {
|
|
79
|
+
const world = new World();
|
|
80
|
+
const material = world.internSharedRef('MaterialAsset', Materials.unlit([1, 1, 1, 1]));
|
|
81
|
+
return { world, material };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function registerPrimitiveWithoutAabb(
|
|
85
|
+
scene: Scene,
|
|
86
|
+
kind: 'cube' | 'triangle',
|
|
87
|
+
): Handle<'MeshAsset', 'shared'> {
|
|
88
|
+
const primitive = createPrimitiveMesh(kind).unwrap();
|
|
89
|
+
const { aabb: _aabb, ...meshWithoutAabb } = primitive;
|
|
90
|
+
return scene.world.internSharedRef('MeshAsset', meshWithoutAabb);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function spawnPerspectiveCamera(world: World, z: number): EntityHandle {
|
|
94
|
+
return world
|
|
95
|
+
.spawn(
|
|
96
|
+
{ component: Transform, data: translateTransform(0, 0, z) },
|
|
97
|
+
{
|
|
98
|
+
component: Camera,
|
|
99
|
+
data: {
|
|
100
|
+
fov: Math.PI / 4,
|
|
101
|
+
aspect: 1,
|
|
102
|
+
near: 0.1,
|
|
103
|
+
far: 100,
|
|
104
|
+
projection: CAMERA_PROJECTION_PERSPECTIVE,
|
|
105
|
+
left: -1,
|
|
106
|
+
right: 1,
|
|
107
|
+
bottom: -1,
|
|
108
|
+
top: 1,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
.unwrap();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Register a mesh with a single triangle for basic hit/miss testing.
|
|
117
|
+
* Triangle vertices: (-0.5,-0.5,0), (0.5,-0.5,0), (0,0.5,0).
|
|
118
|
+
*/
|
|
119
|
+
function registerTriangle(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
120
|
+
const positions = new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0, 0.5, 0]);
|
|
121
|
+
// 12 floats per vertex interleaved (non-skinned stride)
|
|
122
|
+
const v = new Float32Array(36);
|
|
123
|
+
// fill first 3 floats of each vertex with the position for AABB correctness
|
|
124
|
+
for (let i = 0; i < 3; i++) {
|
|
125
|
+
v[i * 12 + 0] = positions[i * 3 + 0] as number;
|
|
126
|
+
v[i * 12 + 1] = positions[i * 3 + 1] as number;
|
|
127
|
+
v[i * 12 + 2] = positions[i * 3 + 2] as number;
|
|
128
|
+
}
|
|
129
|
+
return scene.world.allocSharedRef('MeshAsset', {
|
|
130
|
+
kind: 'mesh',
|
|
131
|
+
vertices: v,
|
|
132
|
+
indices: new Uint16Array([0, 1, 2]),
|
|
133
|
+
attributes: { position: positions },
|
|
134
|
+
aabb: aabbFromPositions(positions),
|
|
135
|
+
submeshes: [
|
|
136
|
+
{ indexOffset: 0, indexCount: 3, vertexCount: 3, topology: 'triangle-list', materialSlot: 0 },
|
|
137
|
+
],
|
|
138
|
+
|
|
139
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Register a cube mesh with 8 vertices at (+-0.5, +-0.5, +-0.5), 12 triangles.
|
|
145
|
+
* Used for vertexIndex / worldPos correctness (AC-03) and multi-candidate (AC-04).
|
|
146
|
+
*/
|
|
147
|
+
function registerCube(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
148
|
+
const positions = new Float32Array([
|
|
149
|
+
-0.5,
|
|
150
|
+
-0.5,
|
|
151
|
+
0.5, // v0
|
|
152
|
+
0.5,
|
|
153
|
+
-0.5,
|
|
154
|
+
0.5, // v1
|
|
155
|
+
0.5,
|
|
156
|
+
0.5,
|
|
157
|
+
0.5, // v2
|
|
158
|
+
-0.5,
|
|
159
|
+
0.5,
|
|
160
|
+
0.5, // v3
|
|
161
|
+
-0.5,
|
|
162
|
+
-0.5,
|
|
163
|
+
-0.5, // v4
|
|
164
|
+
0.5,
|
|
165
|
+
-0.5,
|
|
166
|
+
-0.5, // v5
|
|
167
|
+
0.5,
|
|
168
|
+
0.5,
|
|
169
|
+
-0.5, // v6
|
|
170
|
+
-0.5,
|
|
171
|
+
0.5,
|
|
172
|
+
-0.5, // v7
|
|
173
|
+
]);
|
|
174
|
+
const v = new Float32Array(8 * 12);
|
|
175
|
+
for (let i = 0; i < 8; i++) {
|
|
176
|
+
v[i * 12 + 0] = positions[i * 3 + 0] as number;
|
|
177
|
+
v[i * 12 + 1] = positions[i * 3 + 1] as number;
|
|
178
|
+
v[i * 12 + 2] = positions[i * 3 + 2] as number;
|
|
179
|
+
}
|
|
180
|
+
const indices = new Uint16Array([
|
|
181
|
+
0,
|
|
182
|
+
1,
|
|
183
|
+
2,
|
|
184
|
+
0,
|
|
185
|
+
2,
|
|
186
|
+
3, // front
|
|
187
|
+
4,
|
|
188
|
+
6,
|
|
189
|
+
5,
|
|
190
|
+
4,
|
|
191
|
+
7,
|
|
192
|
+
6, // back
|
|
193
|
+
3,
|
|
194
|
+
2,
|
|
195
|
+
6,
|
|
196
|
+
3,
|
|
197
|
+
6,
|
|
198
|
+
7, // top
|
|
199
|
+
0,
|
|
200
|
+
4,
|
|
201
|
+
5,
|
|
202
|
+
0,
|
|
203
|
+
5,
|
|
204
|
+
1, // bottom
|
|
205
|
+
1,
|
|
206
|
+
5,
|
|
207
|
+
6,
|
|
208
|
+
1,
|
|
209
|
+
6,
|
|
210
|
+
2, // right
|
|
211
|
+
0,
|
|
212
|
+
3,
|
|
213
|
+
7,
|
|
214
|
+
0,
|
|
215
|
+
7,
|
|
216
|
+
4, // left
|
|
217
|
+
]);
|
|
218
|
+
return scene.world.allocSharedRef('MeshAsset', {
|
|
219
|
+
kind: 'mesh',
|
|
220
|
+
vertices: v,
|
|
221
|
+
indices,
|
|
222
|
+
attributes: { position: positions },
|
|
223
|
+
aabb: aabbFromPositions(positions),
|
|
224
|
+
submeshes: [
|
|
225
|
+
{
|
|
226
|
+
indexOffset: 0,
|
|
227
|
+
indexCount: 36,
|
|
228
|
+
vertexCount: 8,
|
|
229
|
+
topology: 'triangle-list',
|
|
230
|
+
materialSlot: 0,
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
|
|
234
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Register a skinned single-triangle mesh (18-floats/vertex stride).
|
|
240
|
+
* Attributes carry skinIndex + skinWeight → isSkinned = true → deformed = true.
|
|
241
|
+
*/
|
|
242
|
+
function registerSkinnedTriangle(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
243
|
+
const positions = new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0, 0.5, 0]);
|
|
244
|
+
const skinIndex = new Uint16Array(3 * 4); // 4 influences per vertex, all 0
|
|
245
|
+
const skinWeight = new Float32Array(3 * 4);
|
|
246
|
+
for (let i = 0; i < 12; i++) skinWeight[i] = 0.25;
|
|
247
|
+
// 18 floats per vertex (12 base + 6 skin)
|
|
248
|
+
const v = new Float32Array(3 * 18);
|
|
249
|
+
for (let i = 0; i < 3; i++) {
|
|
250
|
+
v[i * 18 + 0] = positions[i * 3 + 0] as number;
|
|
251
|
+
v[i * 18 + 1] = positions[i * 3 + 1] as number;
|
|
252
|
+
v[i * 18 + 2] = positions[i * 3 + 2] as number;
|
|
253
|
+
// skinIndex packed as 2 floats at slots 12-13 (zero-initialised by new)
|
|
254
|
+
// skinWeight at slots 14-17
|
|
255
|
+
v[i * 18 + 14] = skinWeight[i * 4 + 0] as number;
|
|
256
|
+
v[i * 18 + 15] = skinWeight[i * 4 + 1] as number;
|
|
257
|
+
v[i * 18 + 16] = skinWeight[i * 4 + 2] as number;
|
|
258
|
+
v[i * 18 + 17] = skinWeight[i * 4 + 3] as number;
|
|
259
|
+
}
|
|
260
|
+
const attrs: VertexAttributeMap = {
|
|
261
|
+
position: positions,
|
|
262
|
+
skinIndex,
|
|
263
|
+
skinWeight,
|
|
264
|
+
};
|
|
265
|
+
return scene.world.allocSharedRef('MeshAsset', {
|
|
266
|
+
kind: 'mesh',
|
|
267
|
+
vertices: v,
|
|
268
|
+
indices: new Uint16Array([0, 1, 2]),
|
|
269
|
+
attributes: attrs,
|
|
270
|
+
aabb: aabbFromPositions(positions),
|
|
271
|
+
submeshes: [
|
|
272
|
+
{ indexOffset: 0, indexCount: 3, vertexCount: 3, topology: 'triangle-list', materialSlot: 0 },
|
|
273
|
+
],
|
|
274
|
+
|
|
275
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function spawnMeshEntity(
|
|
280
|
+
scene: Scene,
|
|
281
|
+
mesh: Handle<'MeshAsset', 'shared'>,
|
|
282
|
+
x: number,
|
|
283
|
+
y: number,
|
|
284
|
+
z: number,
|
|
285
|
+
): EntityHandle {
|
|
286
|
+
return scene.world
|
|
287
|
+
.spawn(
|
|
288
|
+
{ component: Transform, data: translateTransform(x, y, z) },
|
|
289
|
+
{ component: MeshFilter, data: { assetHandle: mesh } },
|
|
290
|
+
{ component: MeshRenderer, data: { materials: [scene.material] } },
|
|
291
|
+
)
|
|
292
|
+
.unwrap();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Wrap pickVertexOnEntity with propagateTransforms preamble (D-9 contract).
|
|
297
|
+
*/
|
|
298
|
+
function runPickVertexOnEntity(
|
|
299
|
+
world: World,
|
|
300
|
+
cameraEntity: EntityHandle,
|
|
301
|
+
screenX: number,
|
|
302
|
+
screenY: number,
|
|
303
|
+
viewportWidth: number,
|
|
304
|
+
viewportHeight: number,
|
|
305
|
+
entity: EntityHandle,
|
|
306
|
+
): VertexHit | undefined;
|
|
307
|
+
function runPickVertexOnEntity(
|
|
308
|
+
world: World,
|
|
309
|
+
cameraEntity: EntityHandle,
|
|
310
|
+
screenX: number,
|
|
311
|
+
screenY: number,
|
|
312
|
+
viewportWidth: number,
|
|
313
|
+
viewportHeight: number,
|
|
314
|
+
entity: EntityHandle,
|
|
315
|
+
options: { limit: number },
|
|
316
|
+
): VertexHit[];
|
|
317
|
+
function runPickVertexOnEntity(
|
|
318
|
+
world: World,
|
|
319
|
+
cameraEntity: EntityHandle,
|
|
320
|
+
screenX: number,
|
|
321
|
+
screenY: number,
|
|
322
|
+
viewportWidth: number,
|
|
323
|
+
viewportHeight: number,
|
|
324
|
+
entity: EntityHandle,
|
|
325
|
+
options?: { limit: number },
|
|
326
|
+
): VertexHit | VertexHit[] | undefined {
|
|
327
|
+
propagateTransforms(world);
|
|
328
|
+
if (options) {
|
|
329
|
+
return pickVertexOnEntity(
|
|
330
|
+
world,
|
|
331
|
+
cameraEntity,
|
|
332
|
+
screenX,
|
|
333
|
+
screenY,
|
|
334
|
+
viewportWidth,
|
|
335
|
+
viewportHeight,
|
|
336
|
+
entity,
|
|
337
|
+
options,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
return pickVertexOnEntity(
|
|
341
|
+
world,
|
|
342
|
+
cameraEntity,
|
|
343
|
+
screenX,
|
|
344
|
+
screenY,
|
|
345
|
+
viewportWidth,
|
|
346
|
+
viewportHeight,
|
|
347
|
+
entity,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Compute view-projection matrix for a perspective camera entity. */
|
|
352
|
+
function computeViewProj(
|
|
353
|
+
world: World,
|
|
354
|
+
cameraEntity: EntityHandle,
|
|
355
|
+
): { view: Float32Array; proj: Float32Array } | undefined {
|
|
356
|
+
const camRes = world.get(cameraEntity, Camera);
|
|
357
|
+
if (!camRes.ok) return undefined;
|
|
358
|
+
const cam = camRes.value;
|
|
359
|
+
const camWorld = readWorldMatrix(world, cameraEntity);
|
|
360
|
+
if (camWorld === undefined) return undefined;
|
|
361
|
+
const view = mat4.create();
|
|
362
|
+
mat4.invert(view, camWorld as unknown as mat4.Mat4Like);
|
|
363
|
+
const proj = mat4.create();
|
|
364
|
+
const kind = cameraProjectionFromF32(cam.projection);
|
|
365
|
+
if (kind === 'orthographic') {
|
|
366
|
+
mat4.orthographic(proj, cam.left, cam.right, cam.bottom, cam.top, cam.near, cam.far);
|
|
367
|
+
} else {
|
|
368
|
+
mat4.perspective(proj, cam.fov, cam.aspect, cam.near, cam.far);
|
|
369
|
+
}
|
|
370
|
+
return { view, proj };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Project a world-space point to screen coordinates using worldToScreen.
|
|
375
|
+
*/
|
|
376
|
+
function projectToScreen(
|
|
377
|
+
worldPos: [number, number, number],
|
|
378
|
+
viewProj: Float32Array,
|
|
379
|
+
): { px: number; py: number; behind: boolean } | undefined {
|
|
380
|
+
const out = vec2.create();
|
|
381
|
+
const result = ray.worldToScreen(
|
|
382
|
+
out,
|
|
383
|
+
worldPos as unknown as Vec3Like,
|
|
384
|
+
viewProj as unknown as import('@forgeax/engine-math').Mat4Like,
|
|
385
|
+
VP,
|
|
386
|
+
VP,
|
|
387
|
+
);
|
|
388
|
+
if (result.behind) return { px: 0, py: 0, behind: true };
|
|
389
|
+
return { px: out[0] as number, py: out[1] as number, behind: false };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Compute perpendicular distance from point to ray in 3D. */
|
|
393
|
+
function pointToRayDist(
|
|
394
|
+
px: number,
|
|
395
|
+
py: number,
|
|
396
|
+
pz: number,
|
|
397
|
+
ox: number,
|
|
398
|
+
oy: number,
|
|
399
|
+
oz: number,
|
|
400
|
+
dx: number,
|
|
401
|
+
dy: number,
|
|
402
|
+
dz: number,
|
|
403
|
+
): number {
|
|
404
|
+
const ex = px - ox,
|
|
405
|
+
ey = py - oy,
|
|
406
|
+
ez = pz - oz;
|
|
407
|
+
const cx = ey * dz - ez * dy;
|
|
408
|
+
const cy = ez * dx - ex * dz;
|
|
409
|
+
const cz = ex * dy - ey * dx;
|
|
410
|
+
return Math.sqrt(cx * cx + cy * cy + cz * cz);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
414
|
+
// w3 — pickVertexOnEntity core behavior tests
|
|
415
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
416
|
+
|
|
417
|
+
describe('pickVertexOnEntity', () => {
|
|
418
|
+
// ── AC-01: return shape ───────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
describe('AC-01: return shape', () => {
|
|
421
|
+
it('without limit returns VertexHit | undefined on hit', () => {
|
|
422
|
+
const scene = makeScene();
|
|
423
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
424
|
+
const mesh = registerTriangle(scene);
|
|
425
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
426
|
+
// screen centre = ray along -Z through triangle at z=0 → hit
|
|
427
|
+
const hit = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity) as
|
|
428
|
+
| VertexHit
|
|
429
|
+
| undefined;
|
|
430
|
+
expect(hit).toBeDefined();
|
|
431
|
+
expect(typeof hit!.vertexIndex).toBe('number');
|
|
432
|
+
expect(typeof hit!.screenDist).toBe('number');
|
|
433
|
+
expect(typeof hit!.worldDist).toBe('number');
|
|
434
|
+
expect(typeof hit!.deformed).toBe('boolean');
|
|
435
|
+
expect(hit!.entity).toBe(entity);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('without limit returns undefined on miss', () => {
|
|
439
|
+
const scene = makeScene();
|
|
440
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
441
|
+
const mesh = registerTriangle(scene);
|
|
442
|
+
// triangle off to the side — centre ray misses
|
|
443
|
+
const entity = spawnMeshEntity(scene, mesh, 10, 10, 0);
|
|
444
|
+
const hit = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity) as
|
|
445
|
+
| VertexHit
|
|
446
|
+
| undefined;
|
|
447
|
+
expect(hit).toBeUndefined();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('with limit returns VertexHit[] sorted by screenDist', () => {
|
|
451
|
+
const scene = makeScene();
|
|
452
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
453
|
+
const mesh = registerCube(scene);
|
|
454
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
455
|
+
const hits = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
456
|
+
limit: 8,
|
|
457
|
+
}) as VertexHit[];
|
|
458
|
+
expect(Array.isArray(hits)).toBe(true);
|
|
459
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
460
|
+
// verify ascending screenDist
|
|
461
|
+
for (let i = 1; i < hits.length; i++) {
|
|
462
|
+
expect(hits[i]!.screenDist).toBeGreaterThanOrEqual(hits[i - 1]!.screenDist);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('with limit returns empty array on miss', () => {
|
|
467
|
+
const scene = makeScene();
|
|
468
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
469
|
+
const mesh = registerTriangle(scene);
|
|
470
|
+
const entity = spawnMeshEntity(scene, mesh, 10, 10, 0);
|
|
471
|
+
const hits = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
472
|
+
limit: 3,
|
|
473
|
+
}) as VertexHit[];
|
|
474
|
+
expect(hits).toEqual([]);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('limit greater than available vertices returns all candidates', () => {
|
|
478
|
+
const scene = makeScene();
|
|
479
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
480
|
+
const mesh = registerCube(scene);
|
|
481
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
482
|
+
const hits = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
483
|
+
limit: 100,
|
|
484
|
+
}) as VertexHit[];
|
|
485
|
+
// cube has 8 vertices; ray through centre hits multiple faces
|
|
486
|
+
expect(hits.length).toBeLessThanOrEqual(100);
|
|
487
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// ── AC-03: vertex worldPos and vertexIndex correctness ─────────────
|
|
492
|
+
|
|
493
|
+
describe('AC-03: vertex worldPos and vertexIndex correctness', () => {
|
|
494
|
+
it('worldPos matches Transform.world x local position for each candidate (cube, epsilon 1e-4)', () => {
|
|
495
|
+
const scene = makeScene();
|
|
496
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
497
|
+
const mesh = registerCube(scene);
|
|
498
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
499
|
+
propagateTransforms(scene.world);
|
|
500
|
+
|
|
501
|
+
const meshRes = resolvePickingAsset<MeshAsset>(scene.world, mesh);
|
|
502
|
+
if (!meshRes.ok) throw new Error('resolve failed');
|
|
503
|
+
const position = meshRes.value.attributes.position as Float32Array;
|
|
504
|
+
const wm = readWorldMatrix(scene.world, entity)!;
|
|
505
|
+
|
|
506
|
+
const hits = pickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
507
|
+
limit: 8,
|
|
508
|
+
});
|
|
509
|
+
expect(Array.isArray(hits)).toBe(true);
|
|
510
|
+
const arr = hits as VertexHit[];
|
|
511
|
+
expect(arr.length).toBeGreaterThan(0);
|
|
512
|
+
|
|
513
|
+
for (const hit of arr) {
|
|
514
|
+
const vi = hit.vertexIndex;
|
|
515
|
+
const lx = position[vi * 3 + 0] as number;
|
|
516
|
+
const ly = position[vi * 3 + 1] as number;
|
|
517
|
+
const lz = position[vi * 3 + 2] as number;
|
|
518
|
+
const expected = vec3.create();
|
|
519
|
+
mat4.transformPoint(
|
|
520
|
+
expected,
|
|
521
|
+
wm as unknown as mat4.Mat4Like,
|
|
522
|
+
[lx, ly, lz] as unknown as Vec3Like,
|
|
523
|
+
);
|
|
524
|
+
expect(hit.worldPos[0]).toBeCloseTo(expected[0] as number, 4);
|
|
525
|
+
expect(hit.worldPos[1]).toBeCloseTo(expected[1] as number, 4);
|
|
526
|
+
expect(hit.worldPos[2]).toBeCloseTo(expected[2] as number, 4);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('worldPos reflects entity Transform translation', () => {
|
|
531
|
+
const scene = makeScene();
|
|
532
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
533
|
+
const mesh = registerTriangle(scene);
|
|
534
|
+
// small offset well within view frustum
|
|
535
|
+
const entity = spawnMeshEntity(scene, mesh, 0.1, 0.2, 0);
|
|
536
|
+
propagateTransforms(scene.world);
|
|
537
|
+
|
|
538
|
+
const meshRes = resolvePickingAsset<MeshAsset>(scene.world, mesh);
|
|
539
|
+
if (!meshRes.ok) throw new Error('resolve failed');
|
|
540
|
+
const position = meshRes.value.attributes.position as Float32Array;
|
|
541
|
+
const wm = readWorldMatrix(scene.world, entity)!;
|
|
542
|
+
|
|
543
|
+
// Verify world mat4 translation is correct
|
|
544
|
+
expect(wm[12]).toBeCloseTo(0.1, 4);
|
|
545
|
+
expect(wm[13]).toBeCloseTo(0.2, 4);
|
|
546
|
+
expect(wm[14]).toBeCloseTo(0, 4);
|
|
547
|
+
|
|
548
|
+
// triangle at (0.1,0.2,0): project its centre to screen
|
|
549
|
+
const vp = computeViewProj(scene.world, camera)!;
|
|
550
|
+
const vpMat = mat4.create();
|
|
551
|
+
mat4.multiply(
|
|
552
|
+
vpMat,
|
|
553
|
+
vp.proj as unknown as mat4.Mat4Like,
|
|
554
|
+
vp.view as unknown as mat4.Mat4Like,
|
|
555
|
+
);
|
|
556
|
+
const centreScreen = projectToScreen([0.1, 0.2, 0], vpMat)!;
|
|
557
|
+
if (centreScreen.behind) throw new Error('centre behind camera');
|
|
558
|
+
|
|
559
|
+
const hits = pickVertexOnEntity(
|
|
560
|
+
scene.world,
|
|
561
|
+
camera,
|
|
562
|
+
centreScreen.px,
|
|
563
|
+
centreScreen.py,
|
|
564
|
+
VP,
|
|
565
|
+
VP,
|
|
566
|
+
entity,
|
|
567
|
+
{ limit: 3 },
|
|
568
|
+
);
|
|
569
|
+
expect(Array.isArray(hits)).toBe(true);
|
|
570
|
+
const arr = hits as VertexHit[];
|
|
571
|
+
expect(arr.length).toBeGreaterThan(0);
|
|
572
|
+
|
|
573
|
+
for (const hit of arr) {
|
|
574
|
+
const vi = hit.vertexIndex;
|
|
575
|
+
const lx = position[vi * 3 + 0] as number;
|
|
576
|
+
const ly = position[vi * 3 + 1] as number;
|
|
577
|
+
const lz = position[vi * 3 + 2] as number;
|
|
578
|
+
const expected = vec3.create();
|
|
579
|
+
mat4.transformPoint(
|
|
580
|
+
expected,
|
|
581
|
+
wm as unknown as mat4.Mat4Like,
|
|
582
|
+
[lx, ly, lz] as unknown as Vec3Like,
|
|
583
|
+
);
|
|
584
|
+
expect(hit.worldPos[0]).toBeCloseTo(expected[0] as number, 4);
|
|
585
|
+
expect(hit.worldPos[1]).toBeCloseTo(expected[1] as number, 4);
|
|
586
|
+
expect(hit.worldPos[2]).toBeCloseTo(expected[2] as number, 4);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// ── AC-04: screenDist sorting ──────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
describe('AC-04: screenDist sorting', () => {
|
|
594
|
+
it('returned array is sorted by screenDist ascending', () => {
|
|
595
|
+
const scene = makeScene();
|
|
596
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
597
|
+
const mesh = registerCube(scene);
|
|
598
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
599
|
+
const hits = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
600
|
+
limit: 8,
|
|
601
|
+
}) as VertexHit[];
|
|
602
|
+
expect(hits.length).toBeGreaterThan(1); // need at least 2 for sort check
|
|
603
|
+
for (let i = 1; i < hits.length; i++) {
|
|
604
|
+
expect(hits[i]!.screenDist).toBeGreaterThanOrEqual(hits[i - 1]!.screenDist);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('screenDist is consistent with worldPos projected to screen (epsilon 0.5px)', () => {
|
|
609
|
+
const scene = makeScene();
|
|
610
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
611
|
+
const mesh = registerCube(scene);
|
|
612
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
613
|
+
propagateTransforms(scene.world);
|
|
614
|
+
|
|
615
|
+
const vp = computeViewProj(scene.world, camera)!;
|
|
616
|
+
const vpMat = mat4.create();
|
|
617
|
+
mat4.multiply(
|
|
618
|
+
vpMat,
|
|
619
|
+
vp.proj as unknown as mat4.Mat4Like,
|
|
620
|
+
vp.view as unknown as mat4.Mat4Like,
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
const hits = pickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
624
|
+
limit: 8,
|
|
625
|
+
}) as VertexHit[];
|
|
626
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
627
|
+
|
|
628
|
+
for (const hit of hits) {
|
|
629
|
+
const screen = projectToScreen(
|
|
630
|
+
[hit.worldPos[0] as number, hit.worldPos[1] as number, hit.worldPos[2] as number],
|
|
631
|
+
vpMat,
|
|
632
|
+
);
|
|
633
|
+
// should not be behind camera
|
|
634
|
+
expect(screen?.behind).toBe(false);
|
|
635
|
+
if (screen) {
|
|
636
|
+
const expectedDist = Math.sqrt((screen.px - VP / 2) ** 2 + (screen.py - VP / 2) ** 2);
|
|
637
|
+
expect(Math.abs(hit.screenDist - expectedDist)).toBeLessThanOrEqual(0.5);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// ── AC-05: worldDist ───────────────────────────────────────────────
|
|
644
|
+
|
|
645
|
+
describe('AC-05: worldDist', () => {
|
|
646
|
+
it('worldDist is non-negative for all hits', () => {
|
|
647
|
+
const scene = makeScene();
|
|
648
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
649
|
+
const mesh = registerCube(scene);
|
|
650
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
651
|
+
const hits = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
652
|
+
limit: 8,
|
|
653
|
+
}) as VertexHit[];
|
|
654
|
+
for (const hit of hits) {
|
|
655
|
+
expect(hit.worldDist).toBeGreaterThanOrEqual(0);
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('worldDist equals point-to-ray perpendicular distance', () => {
|
|
660
|
+
const scene = makeScene();
|
|
661
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
662
|
+
const mesh = registerCube(scene);
|
|
663
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
664
|
+
propagateTransforms(scene.world);
|
|
665
|
+
|
|
666
|
+
// Build the ray manually to verify worldDist
|
|
667
|
+
const camWorld = readWorldMatrix(scene.world, camera)!;
|
|
668
|
+
const view = mat4.create();
|
|
669
|
+
mat4.invert(view, camWorld as unknown as mat4.Mat4Like);
|
|
670
|
+
const proj = mat4.create();
|
|
671
|
+
mat4.perspective(proj, Math.PI / 4, 1, 0.1, 100);
|
|
672
|
+
const r = ray.create();
|
|
673
|
+
ray.screenToRay(
|
|
674
|
+
r,
|
|
675
|
+
VP / 2,
|
|
676
|
+
VP / 2,
|
|
677
|
+
VP,
|
|
678
|
+
VP,
|
|
679
|
+
view as unknown as import('@forgeax/engine-math').Mat4Like,
|
|
680
|
+
proj as unknown as import('@forgeax/engine-math').Mat4Like,
|
|
681
|
+
'perspective',
|
|
682
|
+
);
|
|
683
|
+
const ox = r[0] as number,
|
|
684
|
+
oy = r[1] as number,
|
|
685
|
+
oz = r[2] as number;
|
|
686
|
+
const dx = r[3] as number,
|
|
687
|
+
dy = r[4] as number,
|
|
688
|
+
dz = r[5] as number;
|
|
689
|
+
|
|
690
|
+
const hits = pickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
691
|
+
limit: 8,
|
|
692
|
+
}) as VertexHit[];
|
|
693
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
694
|
+
|
|
695
|
+
for (const hit of hits) {
|
|
696
|
+
const px = hit.worldPos[0] as number;
|
|
697
|
+
const py = hit.worldPos[1] as number;
|
|
698
|
+
const pz = hit.worldPos[2] as number;
|
|
699
|
+
const expectedDist = pointToRayDist(px, py, pz, ox, oy, oz, dx, dy, dz);
|
|
700
|
+
expect(hit.worldDist).toBeCloseTo(expectedDist, 4);
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// ── AC-08: skinned deformed flag ───────────────────────────────────
|
|
706
|
+
|
|
707
|
+
describe('AC-08: skinned deformed flag', () => {
|
|
708
|
+
it('static mesh returns deformed=false', () => {
|
|
709
|
+
const scene = makeScene();
|
|
710
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
711
|
+
const mesh = registerTriangle(scene);
|
|
712
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
713
|
+
const hits = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
714
|
+
limit: 3,
|
|
715
|
+
}) as VertexHit[];
|
|
716
|
+
for (const hit of hits) {
|
|
717
|
+
expect(hit.deformed).toBe(false);
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it('skinned mesh (skinIndex+skinWeight present) returns deformed=true', () => {
|
|
722
|
+
const scene = makeScene();
|
|
723
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
724
|
+
const mesh = registerSkinnedTriangle(scene);
|
|
725
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
726
|
+
propagateTransforms(scene.world);
|
|
727
|
+
|
|
728
|
+
// Verify mesh is skinned
|
|
729
|
+
const meshRes = resolvePickingAsset<MeshAsset>(scene.world, mesh);
|
|
730
|
+
expect(meshRes.ok).toBe(true);
|
|
731
|
+
if (!meshRes.ok) throw new Error('resolve failed');
|
|
732
|
+
const attrs = meshRes.value.attributes;
|
|
733
|
+
expect(attrs.skinIndex).toBeDefined();
|
|
734
|
+
expect(attrs.skinWeight).toBeDefined();
|
|
735
|
+
|
|
736
|
+
const hits = pickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
737
|
+
limit: 3,
|
|
738
|
+
}) as VertexHit[];
|
|
739
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
740
|
+
for (const hit of hits) {
|
|
741
|
+
expect(hit.deformed).toBe(true);
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it('skinned mesh worldPos is rest-pose transformed by Transform.world', () => {
|
|
746
|
+
const scene = makeScene();
|
|
747
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
748
|
+
const mesh = registerSkinnedTriangle(scene);
|
|
749
|
+
// small offset well within view frustum
|
|
750
|
+
const entity = spawnMeshEntity(scene, mesh, 0.1, 0.2, 0);
|
|
751
|
+
propagateTransforms(scene.world);
|
|
752
|
+
|
|
753
|
+
const meshRes = resolvePickingAsset<MeshAsset>(scene.world, mesh);
|
|
754
|
+
if (!meshRes.ok) throw new Error('resolve failed');
|
|
755
|
+
const position = meshRes.value.attributes.position as Float32Array;
|
|
756
|
+
const wm = readWorldMatrix(scene.world, entity)!;
|
|
757
|
+
|
|
758
|
+
expect(wm[12]).toBeCloseTo(0.1, 4);
|
|
759
|
+
expect(wm[13]).toBeCloseTo(0.2, 4);
|
|
760
|
+
|
|
761
|
+
// project triangle centre at (0.1,0.2,0) to screen
|
|
762
|
+
const vp = computeViewProj(scene.world, camera)!;
|
|
763
|
+
const vpMat = mat4.create();
|
|
764
|
+
mat4.multiply(
|
|
765
|
+
vpMat,
|
|
766
|
+
vp.proj as unknown as mat4.Mat4Like,
|
|
767
|
+
vp.view as unknown as mat4.Mat4Like,
|
|
768
|
+
);
|
|
769
|
+
const centreScreen = projectToScreen([0.1, 0.2, 0], vpMat)!;
|
|
770
|
+
if (centreScreen.behind) throw new Error('centre behind camera');
|
|
771
|
+
|
|
772
|
+
const hits = pickVertexOnEntity(
|
|
773
|
+
scene.world,
|
|
774
|
+
camera,
|
|
775
|
+
centreScreen.px,
|
|
776
|
+
centreScreen.py,
|
|
777
|
+
VP,
|
|
778
|
+
VP,
|
|
779
|
+
entity,
|
|
780
|
+
{ limit: 3 },
|
|
781
|
+
) as VertexHit[];
|
|
782
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
783
|
+
|
|
784
|
+
for (const hit of hits) {
|
|
785
|
+
expect(hit.deformed).toBe(true);
|
|
786
|
+
const vi = hit.vertexIndex;
|
|
787
|
+
const lx = position[vi * 3 + 0] as number;
|
|
788
|
+
const ly = position[vi * 3 + 1] as number;
|
|
789
|
+
const lz = position[vi * 3 + 2] as number;
|
|
790
|
+
const expected = vec3.create();
|
|
791
|
+
mat4.transformPoint(
|
|
792
|
+
expected,
|
|
793
|
+
wm as unknown as mat4.Mat4Like,
|
|
794
|
+
[lx, ly, lz] as unknown as Vec3Like,
|
|
795
|
+
);
|
|
796
|
+
// rest-pose worldPos should match the transformed local position
|
|
797
|
+
expect(hit.worldPos[0]).toBeCloseTo(expected[0] as number, 4);
|
|
798
|
+
expect(hit.worldPos[1]).toBeCloseTo(expected[1] as number, 4);
|
|
799
|
+
expect(hit.worldPos[2]).toBeCloseTo(expected[2] as number, 4);
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
// ── AC-10: error protocol ──────────────────────────────────────────
|
|
805
|
+
|
|
806
|
+
describe('AC-10: error protocol', () => {
|
|
807
|
+
it('throws PickError when cameraEntity has no Camera', () => {
|
|
808
|
+
const scene = makeScene();
|
|
809
|
+
const mesh = registerTriangle(scene);
|
|
810
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
811
|
+
const notACamera = scene.world
|
|
812
|
+
.spawn({ component: Transform, data: translateTransform(0, 0, 5) })
|
|
813
|
+
.unwrap();
|
|
814
|
+
expect(() =>
|
|
815
|
+
runPickVertexOnEntity(scene.world, notACamera, VP / 2, VP / 2, VP, VP, entity),
|
|
816
|
+
).toThrow(PickError);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it('returns undefined when camera has Camera but no Transform (recoverable, mirrors pick.ts:124-129)', () => {
|
|
820
|
+
const scene = makeScene();
|
|
821
|
+
const mesh = registerTriangle(scene);
|
|
822
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
823
|
+
const cameraNoTransform = scene.world
|
|
824
|
+
.spawn({
|
|
825
|
+
component: Camera,
|
|
826
|
+
data: {
|
|
827
|
+
fov: Math.PI / 4,
|
|
828
|
+
aspect: 1,
|
|
829
|
+
near: 0.1,
|
|
830
|
+
far: 100,
|
|
831
|
+
projection: CAMERA_PROJECTION_PERSPECTIVE,
|
|
832
|
+
left: -1,
|
|
833
|
+
right: 1,
|
|
834
|
+
bottom: -1,
|
|
835
|
+
top: 1,
|
|
836
|
+
},
|
|
837
|
+
})
|
|
838
|
+
.unwrap();
|
|
839
|
+
const hit = runPickVertexOnEntity(
|
|
840
|
+
scene.world,
|
|
841
|
+
cameraNoTransform,
|
|
842
|
+
VP / 2,
|
|
843
|
+
VP / 2,
|
|
844
|
+
VP,
|
|
845
|
+
VP,
|
|
846
|
+
entity,
|
|
847
|
+
) as VertexHit | undefined;
|
|
848
|
+
expect(hit).toBeUndefined();
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it('returns undefined when no vertices are hit (recoverable miss)', () => {
|
|
852
|
+
const scene = makeScene();
|
|
853
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
854
|
+
const mesh = registerTriangle(scene);
|
|
855
|
+
const entity = spawnMeshEntity(scene, mesh, 10, 10, 0); // far from centre ray
|
|
856
|
+
const hit = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity) as
|
|
857
|
+
| VertexHit
|
|
858
|
+
| undefined;
|
|
859
|
+
expect(hit).toBeUndefined();
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
// ── D-9: propagate preamble ────────────────────────────────────────
|
|
864
|
+
|
|
865
|
+
describe('D-9: propagateTransforms preamble', () => {
|
|
866
|
+
it('returns correct worldPos after propagateTransforms (fresh world column)', () => {
|
|
867
|
+
const scene = makeScene();
|
|
868
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
869
|
+
const mesh = registerTriangle(scene);
|
|
870
|
+
// small offset well within view frustum
|
|
871
|
+
const entity = spawnMeshEntity(scene, mesh, 0.1, 0.2, 0);
|
|
872
|
+
// propagate must be called before pickVertexOnEntity
|
|
873
|
+
propagateTransforms(scene.world);
|
|
874
|
+
|
|
875
|
+
const meshRes = resolvePickingAsset<MeshAsset>(scene.world, mesh);
|
|
876
|
+
if (!meshRes.ok) throw new Error('resolve failed');
|
|
877
|
+
const position = meshRes.value.attributes.position as Float32Array;
|
|
878
|
+
const wm = readWorldMatrix(scene.world, entity)!;
|
|
879
|
+
|
|
880
|
+
// Verify world matrix reflects translation
|
|
881
|
+
// Column-major: translation is at indices 12,13,14
|
|
882
|
+
expect(wm[12]).toBeCloseTo(0.1, 4);
|
|
883
|
+
expect(wm[13]).toBeCloseTo(0.2, 4);
|
|
884
|
+
expect(wm[14]).toBeCloseTo(0, 4);
|
|
885
|
+
|
|
886
|
+
const vp = computeViewProj(scene.world, camera)!;
|
|
887
|
+
const vpMat = mat4.create();
|
|
888
|
+
mat4.multiply(
|
|
889
|
+
vpMat,
|
|
890
|
+
vp.proj as unknown as mat4.Mat4Like,
|
|
891
|
+
vp.view as unknown as mat4.Mat4Like,
|
|
892
|
+
);
|
|
893
|
+
const centreScreen = projectToScreen([0.1, 0.2, 0], vpMat)!;
|
|
894
|
+
if (centreScreen.behind) throw new Error('centre behind');
|
|
895
|
+
|
|
896
|
+
const hits = pickVertexOnEntity(
|
|
897
|
+
scene.world,
|
|
898
|
+
camera,
|
|
899
|
+
centreScreen.px,
|
|
900
|
+
centreScreen.py,
|
|
901
|
+
VP,
|
|
902
|
+
VP,
|
|
903
|
+
entity,
|
|
904
|
+
{ limit: 3 },
|
|
905
|
+
) as VertexHit[];
|
|
906
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
907
|
+
|
|
908
|
+
for (const hit of hits) {
|
|
909
|
+
const vi = hit.vertexIndex;
|
|
910
|
+
const lx = position[vi * 3 + 0] as number;
|
|
911
|
+
const ly = position[vi * 3 + 1] as number;
|
|
912
|
+
const lz = position[vi * 3 + 2] as number;
|
|
913
|
+
const expected = vec3.create();
|
|
914
|
+
mat4.transformPoint(
|
|
915
|
+
expected,
|
|
916
|
+
wm as unknown as mat4.Mat4Like,
|
|
917
|
+
[lx, ly, lz] as unknown as Vec3Like,
|
|
918
|
+
);
|
|
919
|
+
expect(hit.worldPos[0]).toBeCloseTo(expected[0] as number, 4);
|
|
920
|
+
expect(hit.worldPos[1]).toBeCloseTo(expected[1] as number, 4);
|
|
921
|
+
expect(hit.worldPos[2]).toBeCloseTo(expected[2] as number, 4);
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
928
|
+
// w4 — AC-13 narrowing type-check block
|
|
929
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
930
|
+
|
|
931
|
+
describe('AC-13: narrowing type-check', () => {
|
|
932
|
+
it('narrowing probes compile and typecheck correctly (AC-13 true enforcement)', () => {
|
|
933
|
+
// runtime no-op: real AC-13 enforcement is the tsc-only closures below.
|
|
934
|
+
// If overload signatures break narrowing, typecheck will fail before tests run.
|
|
935
|
+
expect(true).toBe(true);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
// tsc-only closure — the body is never executed at runtime but typecheck
|
|
939
|
+
// failure here proves the overload signatures do not narrow correctly.
|
|
940
|
+
const _vertexNarrowingProbe = (world: World, cam: EntityHandle, entity: EntityHandle): void => {
|
|
941
|
+
// No limit -> VertexHit | undefined
|
|
942
|
+
const hit = pickVertexOnEntity(world, cam, 0, 0, VP, VP, entity);
|
|
943
|
+
if (hit) {
|
|
944
|
+
// if(hit) narrows: hit is VertexHit, not undefined
|
|
945
|
+
const e: EntityHandle = hit.entity;
|
|
946
|
+
const vi: number = hit.vertexIndex;
|
|
947
|
+
const wp: ArrayLike<number> = hit.worldPos;
|
|
948
|
+
const sd: number = hit.screenDist;
|
|
949
|
+
const wd: number = hit.worldDist;
|
|
950
|
+
const d: boolean = hit.deformed;
|
|
951
|
+
void e;
|
|
952
|
+
void vi;
|
|
953
|
+
void wp;
|
|
954
|
+
void sd;
|
|
955
|
+
void wd;
|
|
956
|
+
void d;
|
|
957
|
+
// @ts-expect-error — VertexHit has no 'point' field
|
|
958
|
+
void (hit as { point: unknown }).point;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// With limit -> VertexHit[]
|
|
962
|
+
const hits = pickVertexOnEntity(world, cam, 0, 0, VP, VP, entity, { limit: 3 });
|
|
963
|
+
// hits is VertexHit[], so .length and [0] are legal
|
|
964
|
+
const len: number = hits.length;
|
|
965
|
+
const first = hits[0];
|
|
966
|
+
void len;
|
|
967
|
+
void first;
|
|
968
|
+
|
|
969
|
+
// @ts-expect-error — without limit, accessing array methods is a compile error
|
|
970
|
+
const _bad = pickVertexOnEntity(world, cam, 0, 0, VP, VP, entity).length;
|
|
971
|
+
void _bad;
|
|
972
|
+
};
|
|
973
|
+
void _vertexNarrowingProbe;
|
|
974
|
+
|
|
975
|
+
// ── pickVertex narrowing (w7/w8 AC-13) ────────────────────────
|
|
976
|
+
const _pickVertexNarrowingProbe = (world: World, cam: EntityHandle): void => {
|
|
977
|
+
const hit = pickVertex(world, cam, 0, 0, VP, VP);
|
|
978
|
+
if (hit) {
|
|
979
|
+
const e: EntityHandle = hit.entity;
|
|
980
|
+
void e;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const hits = pickVertex(world, cam, 0, 0, VP, VP, { limit: 3 });
|
|
984
|
+
const len: number = hits.length;
|
|
985
|
+
void len;
|
|
986
|
+
|
|
987
|
+
// @ts-expect-error — without limit, accessing array methods is a compile error
|
|
988
|
+
const _bad = pickVertex(world, cam, 0, 0, VP, VP).length;
|
|
989
|
+
void _bad;
|
|
990
|
+
};
|
|
991
|
+
void _pickVertexNarrowingProbe;
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
995
|
+
// w6 — degradation input + builtin fallback tests (RED)
|
|
996
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
997
|
+
//
|
|
998
|
+
// TDD red phase (w6): degradation input tests for pickVertexOnEntity covering
|
|
999
|
+
// AC-09 (strip/u16/undefined position/empty/NaN) + AC-07 (builtin no-AABB
|
|
1000
|
+
// fallback) + R-3 (behind-camera vertex exclusion). These tests are RED
|
|
1001
|
+
// because the M2 pick-vertex.ts does not yet handle these branches.
|
|
1002
|
+
//
|
|
1003
|
+
// Related: plan-tasks.json w6 acceptanceCheck; plan-strategy D-4/D-5 §4 R-3.
|
|
1004
|
+
|
|
1005
|
+
describe('w6: degradation input + builtin fallback', () => {
|
|
1006
|
+
// ── AC-09: triangle-strip submesh → skip ────────────────────────────
|
|
1007
|
+
|
|
1008
|
+
describe('AC-09: triangle-strip submesh skipped', () => {
|
|
1009
|
+
function registerStripMesh(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
1010
|
+
const positions = new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0, 0.5, 0, -0.3, 0.3, 0]);
|
|
1011
|
+
const v = new Float32Array(4 * 12);
|
|
1012
|
+
for (let i = 0; i < 4; i++) {
|
|
1013
|
+
v[i * 12 + 0] = positions[i * 3 + 0] as number;
|
|
1014
|
+
v[i * 12 + 1] = positions[i * 3 + 1] as number;
|
|
1015
|
+
v[i * 12 + 2] = positions[i * 3 + 2] as number;
|
|
1016
|
+
}
|
|
1017
|
+
return scene.world.allocSharedRef('MeshAsset', {
|
|
1018
|
+
kind: 'mesh',
|
|
1019
|
+
vertices: v,
|
|
1020
|
+
indices: new Uint16Array([0, 1, 2, 3]),
|
|
1021
|
+
attributes: { position: positions },
|
|
1022
|
+
submeshes: [
|
|
1023
|
+
{
|
|
1024
|
+
indexOffset: 0,
|
|
1025
|
+
indexCount: 4,
|
|
1026
|
+
vertexCount: 4,
|
|
1027
|
+
topology: 'triangle-strip',
|
|
1028
|
+
materialSlot: 0,
|
|
1029
|
+
},
|
|
1030
|
+
],
|
|
1031
|
+
|
|
1032
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
it('strip submesh returns undefined (no crash, no approximate hit)', () => {
|
|
1037
|
+
const scene = makeScene();
|
|
1038
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1039
|
+
const mesh = registerStripMesh(scene);
|
|
1040
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1041
|
+
const hit = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity) as
|
|
1042
|
+
| VertexHit
|
|
1043
|
+
| undefined;
|
|
1044
|
+
expect(hit).toBeUndefined();
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it('strip submesh with limit returns empty array', () => {
|
|
1048
|
+
const scene = makeScene();
|
|
1049
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1050
|
+
const mesh = registerStripMesh(scene);
|
|
1051
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1052
|
+
const hits = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
1053
|
+
limit: 3,
|
|
1054
|
+
}) as VertexHit[];
|
|
1055
|
+
expect(hits).toEqual([]);
|
|
1056
|
+
});
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
// ── D-4: Uint16Array / undefined position → skip ────────────────────
|
|
1060
|
+
|
|
1061
|
+
describe('D-4: position three-branch narrow (Uint16Array / undefined)', () => {
|
|
1062
|
+
function registerUint16Position(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
1063
|
+
const positions = new Uint16Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
1064
|
+
const v = new Float32Array(3 * 12);
|
|
1065
|
+
return scene.world.allocSharedRef('MeshAsset', {
|
|
1066
|
+
kind: 'mesh',
|
|
1067
|
+
vertices: v,
|
|
1068
|
+
indices: new Uint16Array([0, 1, 2]),
|
|
1069
|
+
attributes: { position: positions },
|
|
1070
|
+
submeshes: [
|
|
1071
|
+
{
|
|
1072
|
+
indexOffset: 0,
|
|
1073
|
+
indexCount: 3,
|
|
1074
|
+
vertexCount: 3,
|
|
1075
|
+
topology: 'triangle-list',
|
|
1076
|
+
materialSlot: 0,
|
|
1077
|
+
},
|
|
1078
|
+
],
|
|
1079
|
+
|
|
1080
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function registerUndefinedPosition(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
1085
|
+
const v = new Float32Array(3 * 12);
|
|
1086
|
+
return scene.world.allocSharedRef('MeshAsset', {
|
|
1087
|
+
kind: 'mesh',
|
|
1088
|
+
vertices: v,
|
|
1089
|
+
indices: new Uint16Array([0, 1, 2]),
|
|
1090
|
+
attributes: {},
|
|
1091
|
+
submeshes: [
|
|
1092
|
+
{
|
|
1093
|
+
indexOffset: 0,
|
|
1094
|
+
indexCount: 3,
|
|
1095
|
+
vertexCount: 3,
|
|
1096
|
+
topology: 'triangle-list',
|
|
1097
|
+
materialSlot: 0,
|
|
1098
|
+
},
|
|
1099
|
+
],
|
|
1100
|
+
|
|
1101
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
it('Uint16Array position returns undefined (skip, no crash)', () => {
|
|
1106
|
+
const scene = makeScene();
|
|
1107
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1108
|
+
const mesh = registerUint16Position(scene);
|
|
1109
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1110
|
+
const hit = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity) as
|
|
1111
|
+
| VertexHit
|
|
1112
|
+
| undefined;
|
|
1113
|
+
expect(hit).toBeUndefined();
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
it('undefined position returns undefined (skip, no crash)', () => {
|
|
1117
|
+
const scene = makeScene();
|
|
1118
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1119
|
+
const mesh = registerUndefinedPosition(scene);
|
|
1120
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1121
|
+
const hit = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity) as
|
|
1122
|
+
| VertexHit
|
|
1123
|
+
| undefined;
|
|
1124
|
+
expect(hit).toBeUndefined();
|
|
1125
|
+
});
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
// ── AC-09: no index buffer → non-indexed triangle sequence ───────
|
|
1129
|
+
|
|
1130
|
+
describe('AC-09: no index buffer — non-indexed triangle sequence', () => {
|
|
1131
|
+
function registerIndexlessTriangle(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
1132
|
+
const positions = new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0, 0.5, 0]);
|
|
1133
|
+
const v = new Float32Array(3 * 12);
|
|
1134
|
+
for (let i = 0; i < 3; i++) {
|
|
1135
|
+
v[i * 12 + 0] = positions[i * 3 + 0] as number;
|
|
1136
|
+
v[i * 12 + 1] = positions[i * 3 + 1] as number;
|
|
1137
|
+
v[i * 12 + 2] = positions[i * 3 + 2] as number;
|
|
1138
|
+
}
|
|
1139
|
+
return scene.world.allocSharedRef('MeshAsset', {
|
|
1140
|
+
kind: 'mesh',
|
|
1141
|
+
vertices: v,
|
|
1142
|
+
attributes: { position: positions },
|
|
1143
|
+
submeshes: [
|
|
1144
|
+
{
|
|
1145
|
+
indexOffset: 0,
|
|
1146
|
+
indexCount: 0,
|
|
1147
|
+
vertexCount: 3,
|
|
1148
|
+
topology: 'triangle-list',
|
|
1149
|
+
materialSlot: 0,
|
|
1150
|
+
},
|
|
1151
|
+
],
|
|
1152
|
+
|
|
1153
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
it('non-indexed mesh hits vertices (every consecutive 3 vertices = one face)', () => {
|
|
1158
|
+
const scene = makeScene();
|
|
1159
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1160
|
+
const mesh = registerIndexlessTriangle(scene);
|
|
1161
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1162
|
+
const hit = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity) as
|
|
1163
|
+
| VertexHit
|
|
1164
|
+
| undefined;
|
|
1165
|
+
expect(hit).toBeDefined();
|
|
1166
|
+
if (hit) {
|
|
1167
|
+
expect(hit.entity).toBe(entity);
|
|
1168
|
+
expect(typeof hit.vertexIndex).toBe('number');
|
|
1169
|
+
expect(hit.vertexIndex >= 0 && hit.vertexIndex < 3).toBe(true);
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
// ── AC-09: empty mesh (0 vertices / 0 triangles) ──────────────────
|
|
1175
|
+
|
|
1176
|
+
describe('AC-09: empty mesh', () => {
|
|
1177
|
+
function registerEmptyMesh(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
1178
|
+
const positions = new Float32Array(0);
|
|
1179
|
+
const v = new Float32Array(0);
|
|
1180
|
+
return scene.world.allocSharedRef('MeshAsset', {
|
|
1181
|
+
kind: 'mesh',
|
|
1182
|
+
vertices: v,
|
|
1183
|
+
attributes: { position: positions },
|
|
1184
|
+
submeshes: [
|
|
1185
|
+
{
|
|
1186
|
+
indexOffset: 0,
|
|
1187
|
+
indexCount: 0,
|
|
1188
|
+
vertexCount: 0,
|
|
1189
|
+
topology: 'triangle-list',
|
|
1190
|
+
materialSlot: 0,
|
|
1191
|
+
},
|
|
1192
|
+
],
|
|
1193
|
+
|
|
1194
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
it('0 vertices returns undefined (no crash)', () => {
|
|
1199
|
+
const scene = makeScene();
|
|
1200
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1201
|
+
const mesh = registerEmptyMesh(scene);
|
|
1202
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1203
|
+
const hit = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity) as
|
|
1204
|
+
| VertexHit
|
|
1205
|
+
| undefined;
|
|
1206
|
+
expect(hit).toBeUndefined();
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
it('0 vertices with limit returns empty array', () => {
|
|
1210
|
+
const scene = makeScene();
|
|
1211
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1212
|
+
const mesh = registerEmptyMesh(scene);
|
|
1213
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1214
|
+
const hits = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
1215
|
+
limit: 3,
|
|
1216
|
+
}) as VertexHit[];
|
|
1217
|
+
expect(hits).toEqual([]);
|
|
1218
|
+
});
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
// ── AC-09: NaN / Inf vertex coordinates → excluded ────────────────
|
|
1222
|
+
|
|
1223
|
+
describe('AC-09: NaN / Inf vertex coordinates excluded', () => {
|
|
1224
|
+
function registerNanMesh(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
1225
|
+
const positions = new Float32Array([NaN, NaN, NaN, 0.5, -0.5, 0, 0, 0.5, 0]);
|
|
1226
|
+
const v = new Float32Array(3 * 12);
|
|
1227
|
+
for (let i = 0; i < 3; i++) {
|
|
1228
|
+
v[i * 12 + 0] = positions[i * 3 + 0] as number;
|
|
1229
|
+
v[i * 12 + 1] = positions[i * 3 + 1] as number;
|
|
1230
|
+
v[i * 12 + 2] = positions[i * 3 + 2] as number;
|
|
1231
|
+
}
|
|
1232
|
+
return scene.world.allocSharedRef('MeshAsset', {
|
|
1233
|
+
kind: 'mesh',
|
|
1234
|
+
vertices: v,
|
|
1235
|
+
indices: new Uint16Array([0, 1, 2]),
|
|
1236
|
+
attributes: { position: positions },
|
|
1237
|
+
submeshes: [
|
|
1238
|
+
{
|
|
1239
|
+
indexOffset: 0,
|
|
1240
|
+
indexCount: 3,
|
|
1241
|
+
vertexCount: 3,
|
|
1242
|
+
topology: 'triangle-list',
|
|
1243
|
+
materialSlot: 0,
|
|
1244
|
+
},
|
|
1245
|
+
],
|
|
1246
|
+
|
|
1247
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
it('NaN vertex coordinates excluded, valid vertices still hit (no NaN propagation)', () => {
|
|
1252
|
+
const scene = makeScene();
|
|
1253
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1254
|
+
const mesh = registerNanMesh(scene);
|
|
1255
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1256
|
+
const hits = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
1257
|
+
limit: 3,
|
|
1258
|
+
}) as VertexHit[];
|
|
1259
|
+
for (const hit of hits) {
|
|
1260
|
+
expect(hit.vertexIndex).not.toBe(0); // vertex 0 is NaN
|
|
1261
|
+
expect(Number.isNaN(hit.worldPos[0])).toBe(false);
|
|
1262
|
+
expect(Number.isNaN(hit.worldPos[1])).toBe(false);
|
|
1263
|
+
expect(Number.isNaN(hit.worldPos[2])).toBe(false);
|
|
1264
|
+
expect(Number.isNaN(hit.screenDist)).toBe(false);
|
|
1265
|
+
expect(Number.isNaN(hit.worldDist)).toBe(false);
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
it('all-NaN positions returns undefined', () => {
|
|
1270
|
+
const scene = makeScene();
|
|
1271
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1272
|
+
const positions = new Float32Array([NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN]);
|
|
1273
|
+
const v = new Float32Array(3 * 12);
|
|
1274
|
+
for (let i = 0; i < 3; i++) {
|
|
1275
|
+
v[i * 12 + 0] = positions[i * 3 + 0] as number;
|
|
1276
|
+
v[i * 12 + 1] = positions[i * 3 + 1] as number;
|
|
1277
|
+
v[i * 12 + 2] = positions[i * 3 + 2] as number;
|
|
1278
|
+
}
|
|
1279
|
+
const mesh = scene.world.allocSharedRef('MeshAsset', {
|
|
1280
|
+
kind: 'mesh',
|
|
1281
|
+
vertices: v,
|
|
1282
|
+
indices: new Uint16Array([0, 1, 2]),
|
|
1283
|
+
attributes: { position: positions },
|
|
1284
|
+
submeshes: [
|
|
1285
|
+
{
|
|
1286
|
+
indexOffset: 0,
|
|
1287
|
+
indexCount: 3,
|
|
1288
|
+
vertexCount: 3,
|
|
1289
|
+
topology: 'triangle-list',
|
|
1290
|
+
materialSlot: 0,
|
|
1291
|
+
},
|
|
1292
|
+
],
|
|
1293
|
+
|
|
1294
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
1295
|
+
});
|
|
1296
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1297
|
+
const hit = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity) as
|
|
1298
|
+
| VertexHit
|
|
1299
|
+
| undefined;
|
|
1300
|
+
expect(hit).toBeUndefined();
|
|
1301
|
+
});
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
// ── AC-07: World-owned primitive mesh without AABB → walk-all-vertices fallback ──
|
|
1305
|
+
|
|
1306
|
+
describe('AC-07: primitive mesh without AABB — walk-all-vertices fallback', () => {
|
|
1307
|
+
it('triangle (no AABB) returns a valid hit via full vertex walk', () => {
|
|
1308
|
+
const scene = makeScene();
|
|
1309
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1310
|
+
const mesh = registerPrimitiveWithoutAabb(scene, 'triangle');
|
|
1311
|
+
const entity = scene.world
|
|
1312
|
+
.spawn(
|
|
1313
|
+
{ component: Transform, data: translateTransform(0, 0, 0) },
|
|
1314
|
+
{ component: MeshFilter, data: { assetHandle: mesh } },
|
|
1315
|
+
{ component: MeshRenderer, data: { materials: [scene.material] } },
|
|
1316
|
+
)
|
|
1317
|
+
.unwrap();
|
|
1318
|
+
propagateTransforms(scene.world);
|
|
1319
|
+
const hit = pickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity) as
|
|
1320
|
+
| VertexHit
|
|
1321
|
+
| undefined;
|
|
1322
|
+
expect(hit).toBeDefined();
|
|
1323
|
+
if (hit) {
|
|
1324
|
+
expect(hit.entity).toBe(entity);
|
|
1325
|
+
expect(typeof hit.worldPos[0]).toBe('number');
|
|
1326
|
+
expect(hit.deformed).toBe(false);
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
it('cube (no AABB) returns valid vertex hits', () => {
|
|
1331
|
+
const scene = makeScene();
|
|
1332
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1333
|
+
const mesh = registerPrimitiveWithoutAabb(scene, 'cube');
|
|
1334
|
+
const entity = scene.world
|
|
1335
|
+
.spawn(
|
|
1336
|
+
{ component: Transform, data: translateTransform(0, 0, 0) },
|
|
1337
|
+
{ component: MeshFilter, data: { assetHandle: mesh } },
|
|
1338
|
+
{ component: MeshRenderer, data: { materials: [scene.material] } },
|
|
1339
|
+
)
|
|
1340
|
+
.unwrap();
|
|
1341
|
+
propagateTransforms(scene.world);
|
|
1342
|
+
const hits = pickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
1343
|
+
limit: 8,
|
|
1344
|
+
}) as VertexHit[];
|
|
1345
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
1346
|
+
for (const h of hits) {
|
|
1347
|
+
expect(h.entity).toBe(entity);
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
// ── R-3: behind-camera vertices excluded ─────────────────────────
|
|
1353
|
+
|
|
1354
|
+
describe('R-3: behind-camera vertices excluded', () => {
|
|
1355
|
+
it('vertex behind camera is excluded from candidates', () => {
|
|
1356
|
+
const scene = makeScene();
|
|
1357
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1358
|
+
const mesh = registerCube(scene);
|
|
1359
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 6); // behind camera at z=5
|
|
1360
|
+
const hit = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity) as
|
|
1361
|
+
| VertexHit
|
|
1362
|
+
| undefined;
|
|
1363
|
+
expect(hit).toBeUndefined();
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
it('screenDist from valid vertices is not NaN when behind is excluded', () => {
|
|
1367
|
+
const scene = makeScene();
|
|
1368
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1369
|
+
const mesh = registerCube(scene);
|
|
1370
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1371
|
+
const hits = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
1372
|
+
limit: 8,
|
|
1373
|
+
}) as VertexHit[];
|
|
1374
|
+
for (const h of hits) {
|
|
1375
|
+
expect(Number.isNaN(h.screenDist)).toBe(false);
|
|
1376
|
+
expect(h.screenDist).toBeGreaterThanOrEqual(0);
|
|
1377
|
+
expect(Number.isNaN(h.worldPos[0])).toBe(false);
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
});
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1384
|
+
// w7 — pickVertex full-scene tests (RED)
|
|
1385
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1386
|
+
//
|
|
1387
|
+
// TDD red phase (w7): full-scene pickVertex tests covering AC-02
|
|
1388
|
+
// (three-state return contract) + R-2 (AABB coarse cull on multi-entity
|
|
1389
|
+
// scenes) + limit > available vertices. These tests are RED because
|
|
1390
|
+
// pickVertex has not been implemented yet.
|
|
1391
|
+
//
|
|
1392
|
+
// Related: plan-tasks.json w7 acceptanceCheck; plan-strategy §4 R-2;
|
|
1393
|
+
// research Finding 1 (reuse pick skeleton archetype walk).
|
|
1394
|
+
|
|
1395
|
+
describe('w7: pickVertex full-scene', () => {
|
|
1396
|
+
// ── AC-02: pickVertex three-state return ──────────────────────────
|
|
1397
|
+
|
|
1398
|
+
describe('AC-02: pickVertex three-state return', () => {
|
|
1399
|
+
it('without limit returns VertexHit | undefined (hit)', () => {
|
|
1400
|
+
const scene = makeScene();
|
|
1401
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1402
|
+
const mesh = registerTriangle(scene);
|
|
1403
|
+
spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1404
|
+
propagateTransforms(scene.world);
|
|
1405
|
+
const hit = pickVertex(scene.world, camera, VP / 2, VP / 2, VP, VP) as VertexHit | undefined;
|
|
1406
|
+
expect(hit).toBeDefined();
|
|
1407
|
+
if (hit) {
|
|
1408
|
+
expect(typeof hit.entity).toBe('number');
|
|
1409
|
+
expect(typeof hit.vertexIndex).toBe('number');
|
|
1410
|
+
}
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
it('without limit returns undefined on miss', () => {
|
|
1414
|
+
const scene = makeScene();
|
|
1415
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1416
|
+
propagateTransforms(scene.world);
|
|
1417
|
+
const hit = pickVertex(scene.world, camera, VP / 2, VP / 2, VP, VP) as VertexHit | undefined;
|
|
1418
|
+
expect(hit).toBeUndefined();
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it('with limit returns VertexHit[] sorted by screenDist', () => {
|
|
1422
|
+
const scene = makeScene();
|
|
1423
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1424
|
+
const mesh = registerCube(scene);
|
|
1425
|
+
spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1426
|
+
propagateTransforms(scene.world);
|
|
1427
|
+
const hits = pickVertex(scene.world, camera, VP / 2, VP / 2, VP, VP, {
|
|
1428
|
+
limit: 8,
|
|
1429
|
+
}) as VertexHit[];
|
|
1430
|
+
expect(Array.isArray(hits)).toBe(true);
|
|
1431
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
1432
|
+
for (let i = 1; i < hits.length; i++) {
|
|
1433
|
+
expect(hits[i]!.screenDist).toBeGreaterThanOrEqual(hits[i - 1]!.screenDist);
|
|
1434
|
+
}
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
it('with limit returns empty array on miss', () => {
|
|
1438
|
+
const scene = makeScene();
|
|
1439
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1440
|
+
propagateTransforms(scene.world);
|
|
1441
|
+
const hits = pickVertex(scene.world, camera, VP / 2, VP / 2, VP, VP, {
|
|
1442
|
+
limit: 3,
|
|
1443
|
+
}) as VertexHit[];
|
|
1444
|
+
expect(hits).toEqual([]);
|
|
1445
|
+
});
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
// ── F-1: limit mode vertexIndex distinctness ────────────────────
|
|
1449
|
+
|
|
1450
|
+
describe('F-1: limit mode returns distinct vertexIndex per entity', () => {
|
|
1451
|
+
it('cube centre-ray 100-limit returns no duplicate (entity, vertexIndex) pairs', () => {
|
|
1452
|
+
const scene = makeScene();
|
|
1453
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1454
|
+
const mesh = registerCube(scene);
|
|
1455
|
+
spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1456
|
+
propagateTransforms(scene.world);
|
|
1457
|
+
|
|
1458
|
+
const hits = pickVertex(scene.world, camera, VP / 2, VP / 2, VP, VP, {
|
|
1459
|
+
limit: 100,
|
|
1460
|
+
}) as VertexHit[];
|
|
1461
|
+
// cube centre ray hits front+back faces; shared vertices appear once each
|
|
1462
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
1463
|
+
|
|
1464
|
+
const seen = new Set<string>();
|
|
1465
|
+
for (const hit of hits) {
|
|
1466
|
+
const key = `${hit.entity}:${hit.vertexIndex}`;
|
|
1467
|
+
expect(seen.has(key)).toBe(false);
|
|
1468
|
+
seen.add(key);
|
|
1469
|
+
}
|
|
1470
|
+
// cube has 8 vertices; centre ray hits at most 8 distinct vertices
|
|
1471
|
+
expect(seen.size).toBeLessThanOrEqual(8);
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
it('pickVertexOnEntity cube centre-ray 100-limit returns no duplicate vertexIndex', () => {
|
|
1475
|
+
const scene = makeScene();
|
|
1476
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1477
|
+
const mesh = registerCube(scene);
|
|
1478
|
+
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1479
|
+
propagateTransforms(scene.world);
|
|
1480
|
+
|
|
1481
|
+
const hits = pickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity, {
|
|
1482
|
+
limit: 100,
|
|
1483
|
+
}) as VertexHit[];
|
|
1484
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
1485
|
+
|
|
1486
|
+
const vertexIndices = new Set<number>();
|
|
1487
|
+
for (const hit of hits) {
|
|
1488
|
+
expect(vertexIndices.has(hit.vertexIndex)).toBe(false);
|
|
1489
|
+
vertexIndices.add(hit.vertexIndex);
|
|
1490
|
+
}
|
|
1491
|
+
expect(vertexIndices.size).toBeLessThanOrEqual(8);
|
|
1492
|
+
});
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
// ── R-2: AABB coarse cull multi-entity ───────────────────────────
|
|
1496
|
+
|
|
1497
|
+
describe('R-2: AABB coarse cull on multi-entity scene', () => {
|
|
1498
|
+
function registerSmallTriangle(
|
|
1499
|
+
scene: Scene,
|
|
1500
|
+
offsetX: number,
|
|
1501
|
+
offsetY: number,
|
|
1502
|
+
): Handle<'MeshAsset', 'shared'> {
|
|
1503
|
+
const positions = new Float32Array([
|
|
1504
|
+
offsetX - 0.3,
|
|
1505
|
+
offsetY - 0.3,
|
|
1506
|
+
0,
|
|
1507
|
+
offsetX + 0.3,
|
|
1508
|
+
offsetY - 0.3,
|
|
1509
|
+
0,
|
|
1510
|
+
offsetX,
|
|
1511
|
+
offsetY + 0.3,
|
|
1512
|
+
0,
|
|
1513
|
+
]);
|
|
1514
|
+
const v = new Float32Array(3 * 12);
|
|
1515
|
+
for (let i = 0; i < 3; i++) {
|
|
1516
|
+
v[i * 12 + 0] = positions[i * 3 + 0] as number;
|
|
1517
|
+
v[i * 12 + 1] = positions[i * 3 + 1] as number;
|
|
1518
|
+
v[i * 12 + 2] = positions[i * 3 + 2] as number;
|
|
1519
|
+
}
|
|
1520
|
+
return scene.world.allocSharedRef('MeshAsset', {
|
|
1521
|
+
kind: 'mesh',
|
|
1522
|
+
vertices: v,
|
|
1523
|
+
indices: new Uint16Array([0, 1, 2]),
|
|
1524
|
+
attributes: { position: positions },
|
|
1525
|
+
aabb: aabbFromPositions(positions),
|
|
1526
|
+
submeshes: [
|
|
1527
|
+
{
|
|
1528
|
+
indexOffset: 0,
|
|
1529
|
+
indexCount: 3,
|
|
1530
|
+
vertexCount: 3,
|
|
1531
|
+
topology: 'triangle-list',
|
|
1532
|
+
materialSlot: 0,
|
|
1533
|
+
},
|
|
1534
|
+
],
|
|
1535
|
+
|
|
1536
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
it('entity far from ray is excluded by AABB coarse cull', () => {
|
|
1541
|
+
const scene = makeScene();
|
|
1542
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1543
|
+
const meshCenter = registerTriangle(scene);
|
|
1544
|
+
const meshFar = registerSmallTriangle(scene, 10, 10);
|
|
1545
|
+
spawnMeshEntity(scene, meshCenter, 0, 0, 0);
|
|
1546
|
+
spawnMeshEntity(scene, meshFar, 10, 10, 0);
|
|
1547
|
+
propagateTransforms(scene.world);
|
|
1548
|
+
|
|
1549
|
+
const hit = pickVertex(scene.world, camera, VP / 2, VP / 2, VP, VP) as VertexHit | undefined;
|
|
1550
|
+
expect(hit).toBeDefined();
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
it('only entities intersecting ray produce candidates', () => {
|
|
1554
|
+
const scene = makeScene();
|
|
1555
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1556
|
+
const mesh1 = registerTriangle(scene);
|
|
1557
|
+
const mesh2 = registerSmallTriangle(scene, 0, 0);
|
|
1558
|
+
spawnMeshEntity(scene, mesh1, 0, 0, 0);
|
|
1559
|
+
spawnMeshEntity(scene, mesh2, 0, 0, 0);
|
|
1560
|
+
propagateTransforms(scene.world);
|
|
1561
|
+
|
|
1562
|
+
const hits = pickVertex(scene.world, camera, VP / 2, VP / 2, VP, VP, {
|
|
1563
|
+
limit: 10,
|
|
1564
|
+
}) as VertexHit[];
|
|
1565
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
1566
|
+
const entityIds = new Set(hits.map((h) => h.entity));
|
|
1567
|
+
expect(entityIds.size).toBeGreaterThanOrEqual(1);
|
|
1568
|
+
});
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
// ── limit > available vertices returns all ───────────────────────
|
|
1572
|
+
|
|
1573
|
+
describe('limit > available vertices returns all', () => {
|
|
1574
|
+
it('limit larger than total candidates returns all available', () => {
|
|
1575
|
+
const scene = makeScene();
|
|
1576
|
+
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1577
|
+
const mesh = registerTriangle(scene);
|
|
1578
|
+
spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1579
|
+
propagateTransforms(scene.world);
|
|
1580
|
+
|
|
1581
|
+
const hits = pickVertex(scene.world, camera, VP / 2, VP / 2, VP, VP, {
|
|
1582
|
+
limit: 100,
|
|
1583
|
+
}) as VertexHit[];
|
|
1584
|
+
expect(hits.length).toBeGreaterThanOrEqual(0);
|
|
1585
|
+
expect(hits.length).toBeLessThanOrEqual(3);
|
|
1586
|
+
});
|
|
1587
|
+
});
|
|
1588
|
+
});
|