@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.
Files changed (51) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +168 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/glyph-text-pick.test.d.ts +2 -0
  5. package/dist/__tests__/glyph-text-pick.test.d.ts.map +1 -0
  6. package/dist/__tests__/pick-errors.test-d.d.ts +2 -0
  7. package/dist/__tests__/pick-errors.test-d.d.ts.map +1 -0
  8. package/dist/__tests__/pick-errors.test.d.ts +2 -0
  9. package/dist/__tests__/pick-errors.test.d.ts.map +1 -0
  10. package/dist/__tests__/pick-tile.test.d.ts +2 -0
  11. package/dist/__tests__/pick-tile.test.d.ts.map +1 -0
  12. package/dist/__tests__/pick-vertex.unit.test.d.ts +2 -0
  13. package/dist/__tests__/pick-vertex.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/pick.test.d.ts +2 -0
  15. package/dist/__tests__/pick.test.d.ts.map +1 -0
  16. package/dist/__tests__/visibility-regression.integration.test.d.ts +2 -0
  17. package/dist/__tests__/visibility-regression.integration.test.d.ts.map +1 -0
  18. package/dist/asset-port.d.ts +6 -0
  19. package/dist/asset-port.d.ts.map +1 -0
  20. package/dist/index.d.ts +9 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.mjs +444 -0
  23. package/dist/index.mjs.map +1 -0
  24. package/dist/pick-core.d.ts +39 -0
  25. package/dist/pick-core.d.ts.map +1 -0
  26. package/dist/pick-errors.d.ts +39 -0
  27. package/dist/pick-errors.d.ts.map +1 -0
  28. package/dist/pick-tile.d.ts +44 -0
  29. package/dist/pick-tile.d.ts.map +1 -0
  30. package/dist/pick-vertex.d.ts +87 -0
  31. package/dist/pick-vertex.d.ts.map +1 -0
  32. package/dist/pick.d.ts +34 -0
  33. package/dist/pick.d.ts.map +1 -0
  34. package/dist/viewport-to-world.d.ts +12 -0
  35. package/dist/viewport-to-world.d.ts.map +1 -0
  36. package/package.json +66 -0
  37. package/src/__tests__/glyph-text-pick.test.ts +131 -0
  38. package/src/__tests__/pick-errors.test-d.ts +66 -0
  39. package/src/__tests__/pick-errors.test.ts +71 -0
  40. package/src/__tests__/pick-tile.test.ts +239 -0
  41. package/src/__tests__/pick-vertex.unit.test.ts +1588 -0
  42. package/src/__tests__/pick.test.ts +525 -0
  43. package/src/__tests__/visibility-regression.integration.test.ts +74 -0
  44. package/src/asset-port.ts +13 -0
  45. package/src/index.ts +21 -0
  46. package/src/pick-core.ts +114 -0
  47. package/src/pick-errors.ts +69 -0
  48. package/src/pick-tile.ts +157 -0
  49. package/src/pick-vertex.ts +593 -0
  50. package/src/pick.ts +148 -0
  51. 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
+ });