@forgeax/engine-picking 0.0.0-dev.8d955ade1c79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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__/helpers/mock-shader-registry.d.ts +3 -0
  7. package/dist/__tests__/helpers/mock-shader-registry.d.ts.map +1 -0
  8. package/dist/__tests__/pick-errors.test-d.d.ts +2 -0
  9. package/dist/__tests__/pick-errors.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/pick-errors.test.d.ts +2 -0
  11. package/dist/__tests__/pick-errors.test.d.ts.map +1 -0
  12. package/dist/__tests__/pick-tile.test.d.ts +2 -0
  13. package/dist/__tests__/pick-tile.test.d.ts.map +1 -0
  14. package/dist/__tests__/pick-vertex.unit.test.d.ts +2 -0
  15. package/dist/__tests__/pick-vertex.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/pick.test.d.ts +2 -0
  17. package/dist/__tests__/pick.test.d.ts.map +1 -0
  18. package/dist/__tests__/visibility-regression.integration.test.d.ts +2 -0
  19. package/dist/__tests__/visibility-regression.integration.test.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 +440 -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 +132 -0
  38. package/src/__tests__/helpers/mock-shader-registry.ts +118 -0
  39. package/src/__tests__/pick-errors.test-d.ts +66 -0
  40. package/src/__tests__/pick-errors.test.ts +71 -0
  41. package/src/__tests__/pick-tile.test.ts +239 -0
  42. package/src/__tests__/pick-vertex.unit.test.ts +1607 -0
  43. package/src/__tests__/pick.test.ts +538 -0
  44. package/src/__tests__/visibility-regression.integration.test.ts +73 -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,538 @@
1
+ // pick.test.ts — screen-to-entity pick() integration tests (AC-05..AC-11).
2
+ //
3
+ // Extracted from packages/runtime/src/__tests__/components.unit.test.ts (the
4
+ // "from pick.test.ts" block) in feat-20260705 M2 / w25 when the pick cluster
5
+ // moved to @forgeax/engine-picking. Runtime can no longer import the picking
6
+ // package (AC-203), so these integration tests live in the downstream picking
7
+ // package that depends on runtime.
8
+
9
+ import { AssetRegistry } from '@forgeax/engine-assets-runtime';
10
+ import { type EntityHandle, World } from '@forgeax/engine-ecs';
11
+ import { AssetGuid } from '@forgeax/engine-pack/guid';
12
+ import {
13
+ CAMERA_PROJECTION_ORTHOGRAPHIC,
14
+ CAMERA_PROJECTION_PERSPECTIVE,
15
+ Camera,
16
+ MeshFilter,
17
+ MeshRenderer,
18
+ } from '@forgeax/engine-render';
19
+ import { ChildOf, propagateTransforms, Transform } from '@forgeax/engine-scene';
20
+ import type { Handle, MaterialAsset, MeshAsset } from '@forgeax/engine-types';
21
+ import { describe, expect, it } from 'vitest';
22
+ import { type PickHit, pick } from '../pick';
23
+ import { PickError } from '../pick-errors';
24
+ import { pickVertex, pickVertexOnEntity } from '../pick-vertex';
25
+ import { viewportToWorld } from '../viewport-to-world';
26
+ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
27
+
28
+ // --- from pick.test.ts ---
29
+ // pick.test.ts — feat-20260529-picking-raycasting-screen-to-entity M3 / w12 (TDD red).
30
+ //
31
+ // Integration tests for the screen-to-entity `pick` free function:
32
+ // pick(world, cameraEntity, screenX, screenY, viewportWidth, viewportHeight)
33
+ // -> PickHit | undefined
34
+ //
35
+ // The deterministic scene is built with a bare `new World()` + a real
36
+ // `AssetRegistry` (the `assets` param introduced by the 2026-05-29 replan: the
37
+ // ray-AABB test needs `MeshAsset.aabb`, which lives ONLY in the AssetRegistry,
38
+ // never on a world column). A full `createRenderer` is intentionally NOT used —
39
+ // it requires a live WebGPU device unavailable in the `pnpm test:unit` project,
40
+ // and `pick` only consumes an `AssetRegistry`, not the renderer. The registry
41
+ // instance IS `renderer.assets` at the demo call site (w16), so the surface
42
+ // under test is identical.
43
+ //
44
+ // Coverage (all pick acceptance criteria):
45
+ // (AC-06) nearest hit wins — two boxes along the ray, the closer one returns
46
+ // (AC-07) miss -> undefined (blank coordinate, no box on the ray)
47
+ // (AC-08) PickHit field shape {entity, point, distance} is exact + correct
48
+ // (AC-10) orthographic camera picks via the parallel-ray path
49
+ // (AC-11) cameraEntity with no Camera -> PickError (structured, not undefined)
50
+ // (clamp) out-of-range / negative screen coords clamp to the viewport edge;
51
+ // NaN/Inf screen coords are sanitized (no NaN ray, no throw)
52
+ //
53
+ // Type-narrowing (AC-05 / AC-08) is asserted in a tsc-only block at the bottom:
54
+ // `const hit = pick(...)` needs no `as` cast, `hit.entity` is accessible after
55
+ // the `if (hit)` guard, and `hit.face` / `hit.uv` are compile errors.
56
+ //
57
+ // Anchors: requirements AC-05..AC-11; plan-strategy D-3 (GlobalTransform fallback
58
+ // to Transform — the flat scene exercises the Transform path) + D-6 (PickHit
59
+ // co-located in pick.ts) + 5.3 (all pick branches must-test); plan-tasks.json w12.
60
+ //
61
+ // TDD red: pick.ts does not exist yet when this file is first committed, so the
62
+ // `../pick` import will not resolve. Green after w13.
63
+
64
+ // feat-20260601 w12/w13: pick reads the resolved `Transform.world` mat4 written
65
+ // by propagateTransforms (no GlobalTransform/Transform fallback). Every scene
66
+ // runs propagate before pick so the world column is fresh; `runPick` folds the
67
+ // propagate + pick pair so the test bodies stay focused on the pick contract.
68
+ function runPick(
69
+ world: World,
70
+ camera: EntityHandle,
71
+ x: number,
72
+ y: number,
73
+ w: number,
74
+ h: number,
75
+ ): PickHit | undefined {
76
+ propagateTransforms(world);
77
+ return pick(world, camera, x, y, w, h);
78
+ }
79
+
80
+ // ── helpers ──────────────────────────────────────────────────────────────
81
+
82
+ function translateTransform(
83
+ x: number,
84
+ y: number,
85
+ z: number,
86
+ ): {
87
+ pos: [number, number, number];
88
+ quat: [number, number, number, number];
89
+ scale: [number, number, number];
90
+ } {
91
+ return {
92
+ pos: [x, y, z],
93
+ quat: [0, 0, 0, 1],
94
+ scale: [1, 1, 1],
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Register a mesh whose computed AABB spans [-0.5, 0.5]^3.
100
+ *
101
+ * The registry computes the AABB from `attributes.position` (an explicit `aabb` is
102
+ * overwritten by `withMeshAabb`), so the position attribute carries the 8 cube corners.
103
+ * `vertices` must be a multiple of the 12-float interleaved stride; a single 3-vertex
104
+ * triangle (36 floats) satisfies the gate while the position attribute drives the AABB.
105
+ */
106
+ function registerBox(world: World, assets: AssetRegistry): Handle<'MeshAsset', 'shared'> {
107
+ const vertices = new Float32Array([
108
+ 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1,
109
+ 0, 0, 0, 0,
110
+ ]);
111
+ // 8 cube corners spanning [-0.5, 0.5] on every axis -> computeAABB = [-0.5,-0.5,-0.5, 0.5,0.5,0.5]
112
+ const positions = new Float32Array([
113
+ -0.5, -0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, -0.5, -0.5, -0.5, 0.5, 0.5, -0.5,
114
+ 0.5, 0.5, 0.5, 0.5, -0.5, 0.5, 0.5,
115
+ ]);
116
+ // catalog computes the local-space AABB (withMeshAabb); mint the augmented
117
+ // payload on the world so resolveAssetHandle (used by pick) reads .aabb.
118
+ const result = assets.catalog<MeshAsset>(AssetGuid.format(AssetGuid.random()), {
119
+ kind: 'mesh',
120
+ vertices,
121
+ indices: new Uint16Array([0, 1, 2]),
122
+ attributes: { position: positions },
123
+ submeshes: [
124
+ {
125
+ indexOffset: 0,
126
+ indexCount: 3,
127
+ vertexCount: vertices.length,
128
+ topology: 'triangle-list',
129
+ materialSlot: 0,
130
+ },
131
+ ],
132
+
133
+ materialSlots: [{ slotName: 'Default' }],
134
+ });
135
+ if (!result.ok) throw new Error('mesh catalog failed');
136
+ return world.allocSharedRef('MeshAsset', result.value);
137
+ }
138
+
139
+ function registerMaterial(world: World, assets: AssetRegistry): Handle<'MaterialAsset', 'shared'> {
140
+ const result = assets.catalog<MaterialAsset>(AssetGuid.format(AssetGuid.random()), {
141
+ kind: 'material',
142
+ passes: [
143
+ {
144
+ name: 'Forward',
145
+ program: { module: 'forgeax::default-unlit' },
146
+ renderState: { tags: { LightMode: 'Forward' }, queue: 2000 },
147
+ },
148
+ ],
149
+ values: { baseColor: [1, 1, 1] },
150
+ });
151
+ if (!result.ok) throw new Error('material catalog failed');
152
+ return world.allocSharedRef('MaterialAsset', result.value);
153
+ }
154
+
155
+ interface Scene {
156
+ world: World;
157
+ assets: AssetRegistry;
158
+ mesh: Handle<'MeshAsset', 'shared'>;
159
+ material: Handle<'MaterialAsset', 'shared'>;
160
+ }
161
+
162
+ function makeScene(): Scene {
163
+ const world = new World();
164
+ const assets = new AssetRegistry(makeMockShaderRegistry());
165
+ const mesh = registerBox(world, assets);
166
+ const material = registerMaterial(world, assets);
167
+ return { world, assets, mesh, material };
168
+ }
169
+
170
+ /** Spawn a perspective camera at (x,y,z) looking down -Z (identity rotation). */
171
+ function spawnPerspectiveCamera(world: World, z: number): EntityHandle {
172
+ return world
173
+ .spawn(
174
+ { component: Transform, data: translateTransform(0, 0, z) },
175
+ {
176
+ component: Camera,
177
+ data: {
178
+ fov: Math.PI / 4,
179
+ aspect: 1,
180
+ near: 0.1,
181
+ far: 100,
182
+ projection: CAMERA_PROJECTION_PERSPECTIVE,
183
+ left: -1,
184
+ right: 1,
185
+ bottom: -1,
186
+ top: 1,
187
+ },
188
+ },
189
+ )
190
+ .unwrap();
191
+ }
192
+
193
+ /** Spawn an orthographic camera at (x,y,z) looking down -Z. */
194
+ function spawnOrthographicCamera(world: World, z: number): EntityHandle {
195
+ return world
196
+ .spawn(
197
+ { component: Transform, data: translateTransform(0, 0, z) },
198
+ {
199
+ component: Camera,
200
+ data: {
201
+ fov: 0,
202
+ aspect: 1,
203
+ near: 0.1,
204
+ far: 100,
205
+ projection: CAMERA_PROJECTION_ORTHOGRAPHIC,
206
+ left: -5,
207
+ right: 5,
208
+ bottom: -5,
209
+ top: 5,
210
+ },
211
+ },
212
+ )
213
+ .unwrap();
214
+ }
215
+
216
+ /** Spawn a box entity at (x,y,z). */
217
+ function spawnBox(scene: Scene, x: number, y: number, z: number): EntityHandle {
218
+ return scene.world
219
+ .spawn(
220
+ { component: Transform, data: translateTransform(x, y, z) },
221
+ { component: MeshFilter, data: { assetHandle: scene.mesh } },
222
+ { component: MeshRenderer, data: { materials: [scene.material] } },
223
+ )
224
+ .unwrap();
225
+ }
226
+
227
+ const VP = 600; // square viewport so screen-centre maps to the -Z axis ray
228
+
229
+ // ── tests ────────────────────────────────────────────────────────────────
230
+
231
+ describe('w12 — pick nearest hit (AC-06)', () => {
232
+ it('returns the closer of two boxes along the ray', () => {
233
+ const scene = makeScene();
234
+ const camera = spawnPerspectiveCamera(scene.world, 5);
235
+ const near = spawnBox(scene, 0, 0, 0); // closer to camera at z=5
236
+ spawnBox(scene, 0, 0, -10); // farther along -Z
237
+
238
+ const hit = runPick(scene.world, camera, VP / 2, VP / 2, VP, VP);
239
+ expect(hit).toBeDefined();
240
+ expect(hit?.entity).toBe(near);
241
+ });
242
+
243
+ it('returns the only box on the ray when a single candidate exists', () => {
244
+ const scene = makeScene();
245
+ const camera = spawnPerspectiveCamera(scene.world, 5);
246
+ const box = spawnBox(scene, 0, 0, 0);
247
+
248
+ const hit = runPick(scene.world, camera, VP / 2, VP / 2, VP, VP);
249
+ expect(hit?.entity).toBe(box);
250
+ });
251
+ });
252
+
253
+ describe('w12 — pick miss (AC-07)', () => {
254
+ it('returns undefined when the ray hits nothing', () => {
255
+ const scene = makeScene();
256
+ const camera = spawnPerspectiveCamera(scene.world, 5);
257
+ // box pushed far off the -Z centre axis; the centre ray misses it
258
+ spawnBox(scene, 50, 0, 0);
259
+
260
+ const hit = runPick(scene.world, camera, VP / 2, VP / 2, VP, VP);
261
+ expect(hit).toBeUndefined();
262
+ });
263
+
264
+ it('returns undefined when the world has no pickable meshes', () => {
265
+ const scene = makeScene();
266
+ const camera = spawnPerspectiveCamera(scene.world, 5);
267
+
268
+ const hit = runPick(scene.world, camera, VP / 2, VP / 2, VP, VP);
269
+ expect(hit).toBeUndefined();
270
+ });
271
+ });
272
+
273
+ describe('w12 — PickHit field shape (AC-08)', () => {
274
+ it('carries entity + point (Vec3) + distance with correct values', () => {
275
+ const scene = makeScene();
276
+ const camera = spawnPerspectiveCamera(scene.world, 5);
277
+ const box = spawnBox(scene, 0, 0, 0);
278
+
279
+ const hit = runPick(scene.world, camera, VP / 2, VP / 2, VP, VP);
280
+ expect(hit).toBeDefined();
281
+ if (!hit) throw new Error('expected hit');
282
+
283
+ expect(hit.entity).toBe(box);
284
+ // The ray origin is the unprojected NEAR-plane point (z = 5 - near = 4.9), not the
285
+ // camera centre; the box front (+Z) face is at z=0.5, so the entry distance along the
286
+ // ray is 4.9 - 0.5 = 4.4 (distance is measured from the near plane, charter D-NDC).
287
+ expect(hit.distance).toBeGreaterThan(0);
288
+ expect(hit.distance).toBeCloseTo(4.4, 1);
289
+ // point = origin + dir * distance; for the centre ray it lands on the +Z face
290
+ expect(hit.point.length).toBe(3);
291
+ expect(hit.point[2]).toBeCloseTo(0.5, 1);
292
+ expect(hit.point[0]).toBeCloseTo(0, 1);
293
+ expect(hit.point[1]).toBeCloseTo(0, 1);
294
+ });
295
+ });
296
+
297
+ describe('w12 — orthographic camera (AC-10)', () => {
298
+ it('picks the box under the screen coordinate via the parallel ray path', () => {
299
+ const scene = makeScene();
300
+ const camera = spawnOrthographicCamera(scene.world, 5);
301
+ const box = spawnBox(scene, 0, 0, 0);
302
+
303
+ const hit = runPick(scene.world, camera, VP / 2, VP / 2, VP, VP);
304
+ expect(hit?.entity).toBe(box);
305
+ });
306
+
307
+ it('orthographic ray translation: off-centre screen coordinate misses a centred box', () => {
308
+ const scene = makeScene();
309
+ const camera = spawnOrthographicCamera(scene.world, 5);
310
+ spawnBox(scene, 0, 0, 0); // box at world origin, ortho span [-5,5]
311
+
312
+ // top-left corner maps to world (-5, +5): far outside the unit box at origin
313
+ const hit = runPick(scene.world, camera, 0, 0, VP, VP);
314
+ expect(hit).toBeUndefined();
315
+ });
316
+ });
317
+
318
+ describe('w12 — camera-missing precondition (AC-11)', () => {
319
+ it('throws a structured PickError when cameraEntity has no Camera', () => {
320
+ const scene = makeScene();
321
+ // entity with a Transform but NO Camera component
322
+ const notACamera = scene.world
323
+ .spawn({ component: Transform, data: translateTransform(0, 0, 5) })
324
+ .unwrap();
325
+ spawnBox(scene, 0, 0, 0);
326
+
327
+ expect(() => runPick(scene.world, notACamera, VP / 2, VP / 2, VP, VP)).toThrow(PickError);
328
+ });
329
+
330
+ it('the PickError carries .code / .expected / .hint / .detail', () => {
331
+ const scene = makeScene();
332
+ const notACamera = scene.world
333
+ .spawn({ component: Transform, data: translateTransform(0, 0, 5) })
334
+ .unwrap();
335
+
336
+ try {
337
+ runPick(scene.world, notACamera, VP / 2, VP / 2, VP, VP);
338
+ throw new Error('expected PickError');
339
+ } catch (e) {
340
+ expect(e).toBeInstanceOf(PickError);
341
+ const err = e as PickError;
342
+ expect(err.code).toBe('camera-component-missing');
343
+ expect(err.expected.length).toBeGreaterThan(0);
344
+ expect(err.hint).toContain('world.set');
345
+ expect(err.detail.cameraEntity).toBe(notACamera as unknown as number);
346
+ }
347
+ });
348
+ });
349
+
350
+ describe('viewportToWorld', () => {
351
+ it('returns the center ray through the camera', () => {
352
+ const scene = makeScene();
353
+ const camera = spawnPerspectiveCamera(scene.world, 5);
354
+ propagateTransforms(scene.world);
355
+
356
+ const result = viewportToWorld(scene.world, camera, VP / 2, VP / 2, VP, VP);
357
+
358
+ expect(result).toBeDefined();
359
+ expect(result?.[0]).toBeCloseTo(0, 4);
360
+ expect(result?.[1]).toBeCloseTo(0, 4);
361
+ expect(result?.[2]).toBeCloseTo(4.9, 4);
362
+ expect(result?.[3]).toBeCloseTo(0, 4);
363
+ expect(result?.[4]).toBeCloseTo(0, 4);
364
+ expect(result?.[5]).toBeCloseTo(-1, 4);
365
+ });
366
+ });
367
+
368
+ describe('degenerate viewport recovery', () => {
369
+ it.each([
370
+ ['zero width', 0, VP],
371
+ ['negative width', -1, VP],
372
+ ['non-finite width NaN', Number.NaN, VP],
373
+ ['non-finite width Infinity', Number.POSITIVE_INFINITY, VP],
374
+ ['zero height', VP, 0],
375
+ ['negative height', VP, -1],
376
+ ['non-finite height NaN', VP, Number.NaN],
377
+ ['non-finite height Infinity', VP, Number.POSITIVE_INFINITY],
378
+ ])('returns the normal miss/no-ray form for %s and recovers on the same scene', (_label, width, height) => {
379
+ const scene = makeScene();
380
+ const camera = spawnPerspectiveCamera(scene.world, 5);
381
+ const box = spawnBox(scene, 0, 0, 0);
382
+ propagateTransforms(scene.world);
383
+
384
+ const expectedRay = viewportToWorld(scene.world, camera, VP / 2, VP / 2, VP, VP);
385
+ const expectedPick = pick(scene.world, camera, VP / 2, VP / 2, VP, VP);
386
+ const expectedSceneVertices = pickVertex(scene.world, camera, VP / 2, VP / 2, VP, VP, {
387
+ limit: 3,
388
+ });
389
+ const expectedEntityVertices = pickVertexOnEntity(
390
+ scene.world,
391
+ camera,
392
+ VP / 2,
393
+ VP / 2,
394
+ VP,
395
+ VP,
396
+ box,
397
+ { limit: 3 },
398
+ );
399
+
400
+ expect(expectedRay).toBeDefined();
401
+ expect(expectedPick?.entity).toBe(box);
402
+ expect(expectedSceneVertices.length).toBeGreaterThan(0);
403
+ expect(expectedEntityVertices.length).toBeGreaterThan(0);
404
+
405
+ expect(pick(scene.world, camera, VP / 2, VP / 2, width, height)).toBeUndefined();
406
+ expect(viewportToWorld(scene.world, camera, VP / 2, VP / 2, width, height)).toBeUndefined();
407
+ expect(pickVertex(scene.world, camera, VP / 2, VP / 2, width, height)).toBeUndefined();
408
+ expect(
409
+ pickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, width, height, box),
410
+ ).toBeUndefined();
411
+ expect(pickVertex(scene.world, camera, VP / 2, VP / 2, width, height, { limit: 3 })).toEqual(
412
+ [],
413
+ );
414
+ expect(
415
+ pickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, width, height, box, { limit: 3 }),
416
+ ).toEqual([]);
417
+
418
+ const recoveredRay = viewportToWorld(scene.world, camera, VP / 2, VP / 2, VP, VP);
419
+ expect(recoveredRay).toBeDefined();
420
+ expect(Array.from(recoveredRay ?? [])).toEqual(Array.from(expectedRay ?? []));
421
+ expect(pick(scene.world, camera, VP / 2, VP / 2, VP, VP)?.entity).toBe(box);
422
+ expect(pickVertex(scene.world, camera, VP / 2, VP / 2, VP, VP)).toEqual(
423
+ expectedSceneVertices[0],
424
+ );
425
+ expect(pickVertex(scene.world, camera, VP / 2, VP / 2, VP, VP, { limit: 3 })).toEqual(
426
+ expectedSceneVertices,
427
+ );
428
+ expect(
429
+ pickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, box, { limit: 3 }),
430
+ ).toEqual(expectedEntityVertices);
431
+ expect(pickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, box)).toEqual(
432
+ expectedEntityVertices[0],
433
+ );
434
+ });
435
+ });
436
+
437
+ describe('w12 — coordinate clamp + sanitization (AC-11 boundary)', () => {
438
+ it('clamps a negative / out-of-range coordinate to the viewport edge without throwing', () => {
439
+ const scene = makeScene();
440
+ const camera = spawnPerspectiveCamera(scene.world, 5);
441
+ spawnBox(scene, 0, 0, 0);
442
+
443
+ // off-screen coordinates: must not throw and must not produce a NaN-driven hit
444
+ expect(() => runPick(scene.world, camera, -100, -100, VP, VP)).not.toThrow();
445
+ expect(() => runPick(scene.world, camera, VP + 999, VP + 999, VP, VP)).not.toThrow();
446
+ });
447
+
448
+ it('sanitizes NaN / Infinity screen coordinates (no throw, defined result)', () => {
449
+ const scene = makeScene();
450
+ const camera = spawnPerspectiveCamera(scene.world, 5);
451
+ spawnBox(scene, 0, 0, 0);
452
+
453
+ expect(() => runPick(scene.world, camera, Number.NaN, 0, VP, VP)).not.toThrow();
454
+ expect(() => runPick(scene.world, camera, Number.POSITIVE_INFINITY, 0, VP, VP)).not.toThrow();
455
+ });
456
+ });
457
+
458
+ // ── tsc-only type-narrowing assertions (AC-05 / AC-08) ─────────────────────
459
+ // These functions are never invoked at runtime; their sole purpose is to make
460
+ // `pnpm run typecheck` (tsc -b) fail if the PickHit surface drifts.
461
+
462
+ describe('w12 — type narrowing (AC-05 / AC-08, tsc)', () => {
463
+ // The runtime body was a no-op probe wrapping `@ts-expect-error` calls; the
464
+ // closure itself is what makes `pnpm run typecheck` fail if PickHit drifts.
465
+ // Hoisting the closure to module scope keeps the typecheck signal without a
466
+ // placeholder runtime assertion (feat-20260608-ci-time-cut).
467
+ const _pickHitTypeProbe = (world: World, cam: EntityHandle): void => {
468
+ // no `as` cast: pick is correctly typed as PickHit | undefined
469
+ const hit = pick(world, cam, 0, 0, VP, VP);
470
+ if (hit) {
471
+ const e: EntityHandle = hit.entity;
472
+ const d: number = hit.distance;
473
+ const p: ArrayLike<number> = hit.point;
474
+ void e;
475
+ void d;
476
+ void p;
477
+ // @ts-expect-error — PickHit has no `face` field (AC-08)
478
+ void hit.face;
479
+ // @ts-expect-error — PickHit has no `uv` field (AC-08)
480
+ void hit.uv;
481
+ }
482
+ // PickHit assignability sanity (no cast required)
483
+ const explicit: PickHit | undefined = pick(world, cam, 1, 1, VP, VP);
484
+ void explicit;
485
+ };
486
+ void _pickHitTypeProbe;
487
+
488
+ it.todo(
489
+ 'PickHit narrows without a cast and rejects absent fields (typecheck-only via _pickHitTypeProbe)',
490
+ );
491
+ });
492
+
493
+ describe('w12 — pick reads Transform.world for hierarchical entities (AC-05)', () => {
494
+ it('picks a child box at its resolved world position (parent x child), not its local position', () => {
495
+ const scene = makeScene();
496
+ const camera = spawnPerspectiveCamera(scene.world, 5);
497
+
498
+ // Parent translates +X by 2; child local sits at the origin. The child's
499
+ // world position is therefore (2,0,0) -- off the centre -Z ray. A pick
500
+ // reading the LOCAL transform (origin) would (wrongly) hit; a pick reading
501
+ // Transform.world (x=2) correctly misses the centre ray.
502
+ const parent = scene.world
503
+ .spawn({ component: Transform, data: translateTransform(2, 0, 0) })
504
+ .unwrap();
505
+ scene.world
506
+ .spawn(
507
+ { component: Transform, data: translateTransform(0, 0, 0) },
508
+ { component: ChildOf, data: { parent } },
509
+ { component: MeshFilter, data: { assetHandle: scene.mesh } },
510
+ { component: MeshRenderer, data: { materials: [scene.material] } },
511
+ )
512
+ .unwrap();
513
+
514
+ // Centre ray (down -Z) misses the world-shifted child.
515
+ expect(runPick(scene.world, camera, VP / 2, VP / 2, VP, VP)).toBeUndefined();
516
+ });
517
+
518
+ it('picks a child box whose world position lands back on the ray', () => {
519
+ const scene = makeScene();
520
+ const camera = spawnPerspectiveCamera(scene.world, 5);
521
+
522
+ // Parent at -X 2, child local +X 2 -> child world (0,0,0) -> on the centre ray.
523
+ const parent = scene.world
524
+ .spawn({ component: Transform, data: translateTransform(-2, 0, 0) })
525
+ .unwrap();
526
+ const child = scene.world
527
+ .spawn(
528
+ { component: Transform, data: translateTransform(2, 0, 0) },
529
+ { component: ChildOf, data: { parent } },
530
+ { component: MeshFilter, data: { assetHandle: scene.mesh } },
531
+ { component: MeshRenderer, data: { materials: [scene.material] } },
532
+ )
533
+ .unwrap();
534
+
535
+ const hit = runPick(scene.world, camera, VP / 2, VP / 2, VP, VP);
536
+ expect(hit?.entity).toBe(child);
537
+ });
538
+ });
@@ -0,0 +1,73 @@
1
+ import { HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
2
+ import { type EntityHandle, World } from '@forgeax/engine-ecs';
3
+ import {
4
+ CAMERA_PROJECTION_PERSPECTIVE,
5
+ Camera,
6
+ MeshFilter,
7
+ MeshRenderer,
8
+ Visibility,
9
+ VisibilityStateValue,
10
+ } from '@forgeax/engine-render';
11
+ import { propagateTransforms, Transform } from '@forgeax/engine-scene';
12
+ import { describe, expect, it } from 'vitest';
13
+ import { extractFrames } from '../../../render/src/render-system-extract';
14
+ import { pick } from '../pick';
15
+
16
+ const VIEWPORT = 600;
17
+
18
+ function transform(pos: [number, number, number]) {
19
+ return {
20
+ pos,
21
+ quat: [0, 0, 0, 1] as [number, number, number, number],
22
+ scale: [1, 1, 1] as [number, number, number],
23
+ };
24
+ }
25
+
26
+ function spawnCamera(world: World): EntityHandle {
27
+ return world
28
+ .spawn(
29
+ { component: Transform, data: transform([0, 0, 5]) },
30
+ {
31
+ component: Camera,
32
+ data: {
33
+ fov: Math.PI / 4,
34
+ aspect: 1,
35
+ near: 0.1,
36
+ far: 100,
37
+ projection: CAMERA_PROJECTION_PERSPECTIVE,
38
+ left: -1,
39
+ right: 1,
40
+ bottom: -1,
41
+ top: 1,
42
+ },
43
+ },
44
+ )
45
+ .unwrap();
46
+ }
47
+
48
+ describe('visibility picking boundary', () => {
49
+ it('keeps pick results unchanged when the render candidate is hidden', () => {
50
+ const world = new World();
51
+ const camera = spawnCamera(world);
52
+ const entity = world
53
+ .spawn(
54
+ { component: Transform, data: transform([0, 0, 0]) },
55
+ { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
56
+ { component: MeshRenderer, data: {} },
57
+ { component: Visibility, data: { state: VisibilityStateValue.visible } },
58
+ )
59
+ .unwrap();
60
+
61
+ propagateTransforms(world);
62
+ const before = pick(world, camera, VIEWPORT / 2, VIEWPORT / 2, VIEWPORT, VIEWPORT);
63
+
64
+ world.set(entity, Visibility, { state: VisibilityStateValue.hidden }).unwrap();
65
+ extractFrames([world], 0);
66
+ propagateTransforms(world);
67
+ const after = pick(world, camera, VIEWPORT / 2, VIEWPORT / 2, VIEWPORT, VIEWPORT);
68
+
69
+ expect(before?.entity).toBe(entity);
70
+ expect(after?.entity).toBe(entity);
71
+ expect(after?.distance).toBeCloseTo(before?.distance ?? -1);
72
+ });
73
+ });
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ // @forgeax/engine-picking -- screen-to-entity + vertex-level + tile-cell picking.
2
+ //
3
+ // All three query surfaces are free functions (NOT methods on World -- charter F1),
4
+ // taking `(world, ...)` and returning a hit / undefined / Result. Extracted from
5
+ // @forgeax/engine-runtime in feat-20260705 (Tier 2.2). The shared skeleton
6
+ // (camera validation -> view=invert -> projection -> screenToRay -> Transform.world)
7
+ // lives once in pick-core.ts (AC-201).
8
+
9
+ // --- screen-to-entity ray-AABB pick (nearest pickable mesh) ---
10
+ export type { PickHit } from './pick';
11
+ export { pick } from './pick';
12
+
13
+ // --- pick error model (closed single-member PickErrorCode union) ---
14
+ export type { PickErrorCode } from './pick-errors';
15
+ export { PickError } from './pick-errors';
16
+ // --- tile-cell pick (Tilemap query) ---
17
+ export { type PickTileError, type PickTileHit, pickTile } from './pick-tile';
18
+ // --- vertex-level pick (per-entity + full-scene) ---
19
+ export type { VertexHit } from './pick-vertex';
20
+ export { pickVertex, pickVertexOnEntity } from './pick-vertex';
21
+ export { viewportToWorld } from './viewport-to-world';