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