@forgeax/engine-picking 0.1.32 → 0.1.34
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/README.md +51 -4
- package/dist/__tests__/display-picking.unit.test.d.ts +2 -0
- package/dist/__tests__/display-picking.unit.test.d.ts.map +1 -0
- package/dist/__tests__/voxel-display-consumer.test-d.d.ts +2 -0
- package/dist/__tests__/voxel-display-consumer.test-d.d.ts.map +1 -0
- package/dist/display-picking.d.ts +27 -0
- package/dist/display-picking.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +455 -273
- package/dist/index.mjs.map +1 -1
- package/dist/pick-core.d.ts +2 -0
- package/dist/pick-core.d.ts.map +1 -1
- package/dist/pick-triangle.d.ts +0 -3
- package/dist/pick-triangle.d.ts.map +1 -1
- package/dist/pick-vertex.d.ts +9 -0
- package/dist/pick-vertex.d.ts.map +1 -1
- package/dist/pick.d.ts +3 -0
- package/dist/pick.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/__tests__/display-picking.unit.test.ts +163 -0
- package/src/__tests__/pick-triangle.unit.test.ts +6 -9
- package/src/__tests__/voxel-display-consumer.test-d.ts +57 -0
- package/src/display-picking.ts +302 -0
- package/src/index.ts +9 -0
- package/src/pick-core.ts +33 -0
- package/src/pick-triangle.ts +3 -12
- package/src/pick-vertex.ts +77 -12
- package/src/pick.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeax/engine-picking",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -22,17 +22,17 @@
|
|
|
22
22
|
"LICENSE"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@forgeax/engine-assets-runtime": "0.1.
|
|
26
|
-
"@forgeax/engine-ecs": "0.1.
|
|
27
|
-
"@forgeax/engine-math": "0.1.
|
|
28
|
-
"@forgeax/engine-render": "0.1.
|
|
29
|
-
"@forgeax/engine-runtime": "0.1.
|
|
30
|
-
"@forgeax/engine-scene": "0.1.
|
|
31
|
-
"@forgeax/engine-types": "0.1.
|
|
25
|
+
"@forgeax/engine-assets-runtime": "0.1.34",
|
|
26
|
+
"@forgeax/engine-ecs": "0.1.34",
|
|
27
|
+
"@forgeax/engine-math": "0.1.34",
|
|
28
|
+
"@forgeax/engine-render": "0.1.34",
|
|
29
|
+
"@forgeax/engine-runtime": "0.1.34",
|
|
30
|
+
"@forgeax/engine-scene": "0.1.34",
|
|
31
|
+
"@forgeax/engine-types": "0.1.34"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@forgeax/engine-pack": "0.1.
|
|
35
|
-
"@forgeax/engine-shader": "0.1.
|
|
34
|
+
"@forgeax/engine-pack": "0.1.34",
|
|
35
|
+
"@forgeax/engine-shader": "0.1.34"
|
|
36
36
|
},
|
|
37
37
|
"forgeax": {
|
|
38
38
|
"metrics": {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { mat4 } from '@forgeax/engine-math';
|
|
3
|
+
import {
|
|
4
|
+
attachBarrelDistortionCameraFrame,
|
|
5
|
+
createBarrelDistortionMapping,
|
|
6
|
+
} from '@forgeax/engine-render';
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import {
|
|
9
|
+
computeDisplayScreenRay,
|
|
10
|
+
displayToScenePixel,
|
|
11
|
+
pickDisplay,
|
|
12
|
+
pickVertexDisplay,
|
|
13
|
+
pickVertexOnEntityDisplay,
|
|
14
|
+
} from '../display-picking';
|
|
15
|
+
|
|
16
|
+
describe('display picking frame authority', () => {
|
|
17
|
+
it('uses the accepted identity frame after an unrendered camera mutation', () => {
|
|
18
|
+
const mapping = attachBarrelDistortionCameraFrame(
|
|
19
|
+
createBarrelDistortionMapping(640, 360, undefined).unwrap(),
|
|
20
|
+
{
|
|
21
|
+
projection: 'perspective',
|
|
22
|
+
far: 100,
|
|
23
|
+
viewMatrix: mat4.create(),
|
|
24
|
+
projectionMatrix: mat4.create(),
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
// A live World fallback would throw here. The submitted camera matrices
|
|
28
|
+
// remain the sole source for the displayed frame after a later mutation.
|
|
29
|
+
const mutatedWorld = {
|
|
30
|
+
get: () => {
|
|
31
|
+
throw new Error('unrendered camera state must not be read');
|
|
32
|
+
},
|
|
33
|
+
} as unknown as World;
|
|
34
|
+
const ray = computeDisplayScreenRay(mutatedWorld, 17 as never, 320, 180, mapping, 640, 360);
|
|
35
|
+
expect(ray).toBeDefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('uses the submitted camera matrices for the display ray', () => {
|
|
39
|
+
const viewMatrix = mat4.create();
|
|
40
|
+
viewMatrix[12] = -0.5;
|
|
41
|
+
const projectionMatrix = mat4.create();
|
|
42
|
+
projectionMatrix[0] = 0.75;
|
|
43
|
+
const mapping = attachBarrelDistortionCameraFrame(
|
|
44
|
+
createBarrelDistortionMapping(64, 64, undefined).unwrap(),
|
|
45
|
+
{
|
|
46
|
+
projection: 'orthographic',
|
|
47
|
+
far: 100,
|
|
48
|
+
viewMatrix,
|
|
49
|
+
projectionMatrix,
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
const world = {
|
|
53
|
+
get: () => {
|
|
54
|
+
throw new Error('display picking must use the accepted mapping');
|
|
55
|
+
},
|
|
56
|
+
} as unknown as World;
|
|
57
|
+
const ray = computeDisplayScreenRay(world, 17 as never, 32, 32, mapping, 64, 64);
|
|
58
|
+
expect(ray).toMatchObject({ projectionKind: 'orthographic' });
|
|
59
|
+
expect(ray).toBeDefined();
|
|
60
|
+
if (ray === undefined) return;
|
|
61
|
+
expect(Array.from(ray.view)).toEqual(Array.from(viewMatrix));
|
|
62
|
+
expect(Array.from(ray.proj)).toEqual(Array.from(projectionMatrix));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('rejects a display query without an accepted frame context', () => {
|
|
66
|
+
const world = {
|
|
67
|
+
get: () => {
|
|
68
|
+
throw new Error('missing frame context must not consult World');
|
|
69
|
+
},
|
|
70
|
+
} as unknown as World;
|
|
71
|
+
expect(
|
|
72
|
+
computeDisplayScreenRay(world, 17 as never, 10, 10, undefined, 640, 360),
|
|
73
|
+
).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects a viewport that disagrees with the submitted mapping extent', () => {
|
|
77
|
+
const mapping = attachBarrelDistortionCameraFrame(
|
|
78
|
+
createBarrelDistortionMapping(640, 360, undefined).unwrap(),
|
|
79
|
+
{
|
|
80
|
+
projection: 'perspective',
|
|
81
|
+
far: 100,
|
|
82
|
+
viewMatrix: mat4.create(),
|
|
83
|
+
projectionMatrix: mat4.create(),
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
const world = {} as World;
|
|
87
|
+
expect(
|
|
88
|
+
computeDisplayScreenRay(world, 17 as never, 640, 360, mapping, 1280, 720),
|
|
89
|
+
).toBeUndefined();
|
|
90
|
+
const out = { x: 0, y: 0 };
|
|
91
|
+
expect(displayToScenePixel(out, undefined, 10, 10)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('uses caller-normalized DPR and embedded offsets while rejecting crop misses', () => {
|
|
95
|
+
const mapping = attachBarrelDistortionCameraFrame(
|
|
96
|
+
createBarrelDistortionMapping(640, 360, undefined).unwrap(),
|
|
97
|
+
{
|
|
98
|
+
projection: 'perspective',
|
|
99
|
+
far: 100,
|
|
100
|
+
viewMatrix: mat4.create(),
|
|
101
|
+
projectionMatrix: mat4.create(),
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
const world = {} as World;
|
|
105
|
+
const embeddedOffset = { left: 37, top: 19 };
|
|
106
|
+
const devicePixelRatio = 2;
|
|
107
|
+
const cssPoint = { x: embeddedOffset.left + 160, y: embeddedOffset.top + 90 };
|
|
108
|
+
const submittedPixel = {
|
|
109
|
+
x: (cssPoint.x - embeddedOffset.left) * devicePixelRatio,
|
|
110
|
+
y: (cssPoint.y - embeddedOffset.top) * devicePixelRatio,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Offset/DPR normalization belongs to the host display adapter. The
|
|
114
|
+
// public picking API consumes the resulting physical pixel against the
|
|
115
|
+
// accepted submitted extent.
|
|
116
|
+
expect(
|
|
117
|
+
computeDisplayScreenRay(
|
|
118
|
+
world,
|
|
119
|
+
17 as never,
|
|
120
|
+
submittedPixel.x,
|
|
121
|
+
submittedPixel.y,
|
|
122
|
+
mapping,
|
|
123
|
+
640,
|
|
124
|
+
360,
|
|
125
|
+
),
|
|
126
|
+
).toBeDefined();
|
|
127
|
+
expect(
|
|
128
|
+
computeDisplayScreenRay(world, 17 as never, -1, submittedPixel.y, mapping, 640, 360),
|
|
129
|
+
).toBeUndefined();
|
|
130
|
+
expect(
|
|
131
|
+
computeDisplayScreenRay(world, 17 as never, 640, submittedPixel.y, mapping, 640, 360),
|
|
132
|
+
).toBeDefined();
|
|
133
|
+
expect(
|
|
134
|
+
computeDisplayScreenRay(world, 17 as never, 641, submittedPixel.y, mapping, 640, 360),
|
|
135
|
+
).toBeUndefined();
|
|
136
|
+
// A DPR-sized viewport is a different submitted extent and must fail
|
|
137
|
+
// closed instead of silently selecting a second display mapping.
|
|
138
|
+
expect(
|
|
139
|
+
computeDisplayScreenRay(
|
|
140
|
+
world,
|
|
141
|
+
17 as never,
|
|
142
|
+
submittedPixel.x,
|
|
143
|
+
submittedPixel.y,
|
|
144
|
+
mapping,
|
|
145
|
+
1280,
|
|
146
|
+
720,
|
|
147
|
+
),
|
|
148
|
+
).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('fails closed for omitted display context across every display entrypoint', () => {
|
|
152
|
+
const world = {
|
|
153
|
+
get: () => {
|
|
154
|
+
throw new Error('missing frame context must not consult World');
|
|
155
|
+
},
|
|
156
|
+
} as unknown as World;
|
|
157
|
+
expect(pickDisplay(world, 17 as never, 10, 10, undefined, 640, 360)).toBeUndefined();
|
|
158
|
+
expect(pickVertexDisplay(world, 17 as never, 10, 10, undefined, 640, 360)).toBeUndefined();
|
|
159
|
+
expect(
|
|
160
|
+
pickVertexOnEntityDisplay(world, 17 as never, 10, 10, undefined, 640, 360, 18 as never),
|
|
161
|
+
).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -4,7 +4,6 @@ import { AssetGuid } from '@forgeax/engine-pack/guid';
|
|
|
4
4
|
import {
|
|
5
5
|
CAMERA_PROJECTION_PERSPECTIVE,
|
|
6
6
|
Camera,
|
|
7
|
-
InstanceCollectionStore,
|
|
8
7
|
Instances,
|
|
9
8
|
MeshFilter,
|
|
10
9
|
MeshRenderer,
|
|
@@ -48,7 +47,6 @@ function registerMesh(
|
|
|
48
47
|
function makeScene() {
|
|
49
48
|
const world = new World();
|
|
50
49
|
const assets = new AssetRegistry(makeMockShaderRegistry());
|
|
51
|
-
const instanceCollections = new InstanceCollectionStore();
|
|
52
50
|
const material = assets.catalog(AssetGuid.format(AssetGuid.random()), {
|
|
53
51
|
kind: 'material',
|
|
54
52
|
passes: [
|
|
@@ -81,7 +79,7 @@ function makeScene() {
|
|
|
81
79
|
},
|
|
82
80
|
)
|
|
83
81
|
.unwrap();
|
|
84
|
-
return { world, assets, materialHandle, camera
|
|
82
|
+
return { world, assets, materialHandle, camera };
|
|
85
83
|
}
|
|
86
84
|
|
|
87
85
|
function spawnMesh(
|
|
@@ -119,7 +117,6 @@ function spawnInstancedMesh(
|
|
|
119
117
|
transforms[offset + 15] = 1;
|
|
120
118
|
transforms[offset + 12] = translations[index] as number;
|
|
121
119
|
}
|
|
122
|
-
const collection = scene.instanceCollections.create({ transforms }).unwrap();
|
|
123
120
|
return scene.world
|
|
124
121
|
.spawn(
|
|
125
122
|
{
|
|
@@ -128,7 +125,7 @@ function spawnInstancedMesh(
|
|
|
128
125
|
},
|
|
129
126
|
{ component: MeshFilter, data: { assetHandle: mesh } },
|
|
130
127
|
{ component: MeshRenderer, data: { materials: [scene.materialHandle] } },
|
|
131
|
-
{ component: Instances, data: {
|
|
128
|
+
{ component: Instances, data: { transforms } },
|
|
132
129
|
)
|
|
133
130
|
.unwrap();
|
|
134
131
|
}
|
|
@@ -239,14 +236,14 @@ describe('pickTriangle', () => {
|
|
|
239
236
|
);
|
|
240
237
|
const entity = spawnInstancedMesh(scene, mesh, [5, 0]);
|
|
241
238
|
|
|
242
|
-
const result = query(scene, VP / 2, VP / 2
|
|
239
|
+
const result = query(scene, VP / 2, VP / 2);
|
|
243
240
|
expect(result.status).toBe('hit');
|
|
244
241
|
if (result.status !== 'hit') return;
|
|
245
242
|
expect(result.hit.entity).toBe(entity);
|
|
246
243
|
expect(result.hit.instanceIndex).toBe(1);
|
|
247
244
|
});
|
|
248
245
|
|
|
249
|
-
it('fails closed when
|
|
246
|
+
it('fails closed when authored Instances contain an incomplete matrix', () => {
|
|
250
247
|
const scene = makeScene();
|
|
251
248
|
const mesh = registerMesh(
|
|
252
249
|
scene.world,
|
|
@@ -254,9 +251,9 @@ describe('pickTriangle', () => {
|
|
|
254
251
|
[-0.5, -0.5, 0, 0.5, -0.5, 0, 0, 0.5, 0],
|
|
255
252
|
[0, 1, 2],
|
|
256
253
|
);
|
|
257
|
-
spawnInstancedMesh(scene, mesh, [0]);
|
|
254
|
+
const entity = spawnInstancedMesh(scene, mesh, [0]);
|
|
255
|
+
scene.world.set(entity, Instances, { transforms: new Float32Array(15) }).unwrap();
|
|
258
256
|
|
|
259
|
-
expect(query(scene).status).toBe('unavailable');
|
|
260
257
|
const result = query(scene);
|
|
261
258
|
expect(result.status).toBe('unavailable');
|
|
262
259
|
if (result.status === 'unavailable') {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Public Voxel L1/L6 consumer contract.
|
|
2
|
+
//
|
|
3
|
+
// Voxel owns grab/split and target succession. Engine owns the submitted
|
|
4
|
+
// display mapping; this fixture keeps both routes typechecked without importing
|
|
5
|
+
// a private renderer or creating a second spatial transform.
|
|
6
|
+
import type { EntityHandle, World } from '@forgeax/engine-ecs';
|
|
7
|
+
import type { FrameReceipt } from '@forgeax/engine-render';
|
|
8
|
+
import { mapSceneToDisplay } from '@forgeax/engine-render';
|
|
9
|
+
import { computeDisplayScreenRay, pickDisplay, pickVertexDisplay } from '../index';
|
|
10
|
+
|
|
11
|
+
declare const world: World;
|
|
12
|
+
declare const camera: EntityHandle;
|
|
13
|
+
declare const target: EntityHandle;
|
|
14
|
+
declare const receipt: FrameReceipt;
|
|
15
|
+
declare const outputWidth: number;
|
|
16
|
+
declare const outputHeight: number;
|
|
17
|
+
declare const pointerX: number;
|
|
18
|
+
declare const pointerY: number;
|
|
19
|
+
|
|
20
|
+
const mapping = receipt.barrelDistortion;
|
|
21
|
+
if (mapping !== undefined) {
|
|
22
|
+
const ray = computeDisplayScreenRay(
|
|
23
|
+
world,
|
|
24
|
+
camera,
|
|
25
|
+
pointerX,
|
|
26
|
+
pointerY,
|
|
27
|
+
mapping,
|
|
28
|
+
outputWidth,
|
|
29
|
+
outputHeight,
|
|
30
|
+
);
|
|
31
|
+
const entityHit = pickDisplay(
|
|
32
|
+
world,
|
|
33
|
+
camera,
|
|
34
|
+
pointerX,
|
|
35
|
+
pointerY,
|
|
36
|
+
mapping,
|
|
37
|
+
outputWidth,
|
|
38
|
+
outputHeight,
|
|
39
|
+
);
|
|
40
|
+
const vertexHits = pickVertexDisplay(
|
|
41
|
+
world,
|
|
42
|
+
camera,
|
|
43
|
+
pointerX,
|
|
44
|
+
pointerY,
|
|
45
|
+
mapping,
|
|
46
|
+
outputWidth,
|
|
47
|
+
outputHeight,
|
|
48
|
+
{ limit: 8, radius: 6 },
|
|
49
|
+
);
|
|
50
|
+
const label = { x: 0, y: 0 };
|
|
51
|
+
const labelVisible = mapSceneToDisplay(label, mapping, pointerX, pointerY);
|
|
52
|
+
void ray;
|
|
53
|
+
void entityHit;
|
|
54
|
+
void vertexHits;
|
|
55
|
+
void target;
|
|
56
|
+
void labelVisible;
|
|
57
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import type { EntityHandle, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { mat4, ray, type Vec3Like, vec2 } from '@forgeax/engine-math';
|
|
3
|
+
import {
|
|
4
|
+
type BarrelDistortionMapping,
|
|
5
|
+
type DisplayPoint,
|
|
6
|
+
mapDisplayToScene,
|
|
7
|
+
mapSceneToDisplay,
|
|
8
|
+
} from '@forgeax/engine-render';
|
|
9
|
+
import { type PickHit, pickWithScreenRay } from './pick';
|
|
10
|
+
import { computeScreenRayFromMatrices, type ScreenRay } from './pick-core';
|
|
11
|
+
import {
|
|
12
|
+
pickVertexOnEntityWithScreenRay,
|
|
13
|
+
pickVertexWithScreenRay,
|
|
14
|
+
type VertexHit,
|
|
15
|
+
} from './pick-vertex';
|
|
16
|
+
|
|
17
|
+
export interface DisplayVertexPickOptions {
|
|
18
|
+
readonly limit?: number;
|
|
19
|
+
/** Maximum distance in displayed physical pixels; omitted keeps all ray hits. */
|
|
20
|
+
readonly radius?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Convert a displayed output pixel to the matching unwarped scene pixel.
|
|
25
|
+
*
|
|
26
|
+
* `undefined` is the fail-closed signal that no accepted submitted frame is
|
|
27
|
+
* available. It is not an identity mapping; callers must wait for a new
|
|
28
|
+
* submitted frame (including an explicit zero-strength mapping).
|
|
29
|
+
*/
|
|
30
|
+
export function displayToScenePixel(
|
|
31
|
+
out: DisplayPoint,
|
|
32
|
+
mapping: BarrelDistortionMapping | undefined,
|
|
33
|
+
displayX: number,
|
|
34
|
+
displayY: number,
|
|
35
|
+
): boolean {
|
|
36
|
+
if (mapping === undefined) return false;
|
|
37
|
+
return mapDisplayToScene(out, mapping, displayX, displayY);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Display coordinates are meaningful only against the accepted submitted
|
|
42
|
+
* output extent. Keep the legacy viewport arguments as an explicit boundary
|
|
43
|
+
* check for callers migrating from unwarped picking; never let them select a
|
|
44
|
+
* second ray viewport or manufacture an identity mapping. An omitted mapping
|
|
45
|
+
* therefore rejects the query before any World or camera access.
|
|
46
|
+
*/
|
|
47
|
+
function submittedExtent(
|
|
48
|
+
mapping: BarrelDistortionMapping | undefined,
|
|
49
|
+
viewportWidth: number,
|
|
50
|
+
viewportHeight: number,
|
|
51
|
+
): { readonly width: number; readonly height: number } | undefined {
|
|
52
|
+
if (mapping === undefined) return undefined;
|
|
53
|
+
if (
|
|
54
|
+
!Number.isFinite(mapping.width) ||
|
|
55
|
+
!Number.isFinite(mapping.height) ||
|
|
56
|
+
mapping.width <= 0 ||
|
|
57
|
+
mapping.height <= 0 ||
|
|
58
|
+
!Number.isFinite(viewportWidth) ||
|
|
59
|
+
!Number.isFinite(viewportHeight) ||
|
|
60
|
+
viewportWidth !== mapping.width ||
|
|
61
|
+
viewportHeight !== mapping.height
|
|
62
|
+
) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
return { width: mapping.width, height: mapping.height };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Build a world ray from the physical pixel currently visible on screen. */
|
|
69
|
+
export function computeDisplayScreenRay(
|
|
70
|
+
world: World,
|
|
71
|
+
cameraEntity: EntityHandle,
|
|
72
|
+
displayX: number,
|
|
73
|
+
displayY: number,
|
|
74
|
+
mapping: BarrelDistortionMapping | undefined,
|
|
75
|
+
viewportWidth: number,
|
|
76
|
+
viewportHeight: number,
|
|
77
|
+
): ScreenRay | undefined {
|
|
78
|
+
// The signature retains the world/entity pair for the shared picking
|
|
79
|
+
// surface, but an accepted frame context is mandatory for display rays.
|
|
80
|
+
void world;
|
|
81
|
+
void cameraEntity;
|
|
82
|
+
const extent = submittedExtent(mapping, viewportWidth, viewportHeight);
|
|
83
|
+
if (
|
|
84
|
+
extent === undefined ||
|
|
85
|
+
!Number.isFinite(displayX) ||
|
|
86
|
+
!Number.isFinite(displayY) ||
|
|
87
|
+
displayX < 0 ||
|
|
88
|
+
displayX > extent.width ||
|
|
89
|
+
displayY < 0 ||
|
|
90
|
+
displayY > extent.height
|
|
91
|
+
) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
const scene = { x: 0, y: 0 };
|
|
95
|
+
if (!displayToScenePixel(scene, mapping, displayX, displayY)) return undefined;
|
|
96
|
+
return computeSceneScreenRay(scene.x, scene.y, mapping);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function computeSceneScreenRay(
|
|
100
|
+
sceneX: number,
|
|
101
|
+
sceneY: number,
|
|
102
|
+
mapping: BarrelDistortionMapping | undefined,
|
|
103
|
+
): ScreenRay | undefined {
|
|
104
|
+
if (mapping === undefined || mapping.camera === undefined) return undefined;
|
|
105
|
+
const camera = mapping.camera;
|
|
106
|
+
const submitted = mapping;
|
|
107
|
+
return computeScreenRayFromMatrices(
|
|
108
|
+
sceneX,
|
|
109
|
+
sceneY,
|
|
110
|
+
submitted.width,
|
|
111
|
+
submitted.height,
|
|
112
|
+
camera.viewMatrix,
|
|
113
|
+
camera.projectionMatrix,
|
|
114
|
+
camera.projection,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Explicit display-space entity pick; legacy `pick` remains unwarped. */
|
|
119
|
+
export function pickDisplay(
|
|
120
|
+
world: World,
|
|
121
|
+
_cameraEntity: EntityHandle,
|
|
122
|
+
displayX: number,
|
|
123
|
+
displayY: number,
|
|
124
|
+
mapping: BarrelDistortionMapping | undefined,
|
|
125
|
+
viewportWidth: number,
|
|
126
|
+
viewportHeight: number,
|
|
127
|
+
): PickHit | undefined {
|
|
128
|
+
const extent = submittedExtent(mapping, viewportWidth, viewportHeight);
|
|
129
|
+
if (
|
|
130
|
+
extent === undefined ||
|
|
131
|
+
!Number.isFinite(displayX) ||
|
|
132
|
+
!Number.isFinite(displayY) ||
|
|
133
|
+
displayX < 0 ||
|
|
134
|
+
displayX > extent.width ||
|
|
135
|
+
displayY < 0 ||
|
|
136
|
+
displayY > extent.height
|
|
137
|
+
) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const scene = { x: 0, y: 0 };
|
|
141
|
+
if (!displayToScenePixel(scene, mapping, displayX, displayY)) return undefined;
|
|
142
|
+
const screenRay = computeSceneScreenRay(scene.x, scene.y, mapping);
|
|
143
|
+
if (screenRay === undefined) return undefined;
|
|
144
|
+
return pickWithScreenRay(world, screenRay);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function remapVertexHits(
|
|
148
|
+
hits: readonly VertexHit[],
|
|
149
|
+
displayX: number,
|
|
150
|
+
displayY: number,
|
|
151
|
+
mapping: BarrelDistortionMapping | undefined,
|
|
152
|
+
screenRay: ScreenRay,
|
|
153
|
+
viewportWidth: number,
|
|
154
|
+
viewportHeight: number,
|
|
155
|
+
radius: number | undefined,
|
|
156
|
+
): VertexHit[] {
|
|
157
|
+
const viewProj = mat4.create();
|
|
158
|
+
mat4.multiply(viewProj, screenRay.proj, screenRay.view);
|
|
159
|
+
const scenePoint = vec2.create();
|
|
160
|
+
const displayPoint: DisplayPoint = { x: 0, y: 0 };
|
|
161
|
+
const remapped: VertexHit[] = [];
|
|
162
|
+
for (const hit of hits) {
|
|
163
|
+
const projected = ray.worldToScreen(
|
|
164
|
+
scenePoint,
|
|
165
|
+
hit.worldPos as unknown as Vec3Like,
|
|
166
|
+
viewProj,
|
|
167
|
+
viewportWidth,
|
|
168
|
+
viewportHeight,
|
|
169
|
+
);
|
|
170
|
+
if (projected.behind) continue;
|
|
171
|
+
if (
|
|
172
|
+
mapping === undefined ||
|
|
173
|
+
!mapSceneToDisplay(displayPoint, mapping, scenePoint[0] as number, scenePoint[1] as number)
|
|
174
|
+
)
|
|
175
|
+
continue;
|
|
176
|
+
const dx = displayPoint.x - displayX;
|
|
177
|
+
const dy = displayPoint.y - displayY;
|
|
178
|
+
const screenDist = Math.hypot(dx, dy);
|
|
179
|
+
if (radius !== undefined && (!Number.isFinite(radius) || screenDist > radius)) continue;
|
|
180
|
+
remapped.push({ ...hit, screenDist });
|
|
181
|
+
}
|
|
182
|
+
remapped.sort((left, right) => left.screenDist - right.screenDist);
|
|
183
|
+
return remapped;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function displayVertexQuery(
|
|
187
|
+
displayX: number,
|
|
188
|
+
displayY: number,
|
|
189
|
+
mapping: BarrelDistortionMapping | undefined,
|
|
190
|
+
viewportWidth: number,
|
|
191
|
+
viewportHeight: number,
|
|
192
|
+
): DisplayPoint | undefined {
|
|
193
|
+
const extent = submittedExtent(mapping, viewportWidth, viewportHeight);
|
|
194
|
+
if (
|
|
195
|
+
extent === undefined ||
|
|
196
|
+
!Number.isFinite(displayX) ||
|
|
197
|
+
!Number.isFinite(displayY) ||
|
|
198
|
+
displayX < 0 ||
|
|
199
|
+
displayX > extent.width ||
|
|
200
|
+
displayY < 0 ||
|
|
201
|
+
displayY > extent.height
|
|
202
|
+
) {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
const scene = { x: 0, y: 0 };
|
|
206
|
+
if (!displayToScenePixel(scene, mapping, displayX, displayY)) return undefined;
|
|
207
|
+
return scene;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Pick vertices using displayed-pixel radius and ordering under the same mapping. */
|
|
211
|
+
export function pickVertexDisplay(
|
|
212
|
+
world: World,
|
|
213
|
+
_cameraEntity: EntityHandle,
|
|
214
|
+
displayX: number,
|
|
215
|
+
displayY: number,
|
|
216
|
+
mapping: BarrelDistortionMapping | undefined,
|
|
217
|
+
viewportWidth: number,
|
|
218
|
+
viewportHeight: number,
|
|
219
|
+
options?: DisplayVertexPickOptions,
|
|
220
|
+
): VertexHit | VertexHit[] | undefined {
|
|
221
|
+
const scene = displayVertexQuery(displayX, displayY, mapping, viewportWidth, viewportHeight);
|
|
222
|
+
if (scene === undefined) return options === undefined ? undefined : [];
|
|
223
|
+
const screenRay = computeSceneScreenRay(scene.x, scene.y, mapping);
|
|
224
|
+
if (screenRay === undefined) return options === undefined ? undefined : [];
|
|
225
|
+
const extent =
|
|
226
|
+
mapping === undefined ? undefined : { width: mapping.width, height: mapping.height };
|
|
227
|
+
if (extent === undefined) return options === undefined ? undefined : [];
|
|
228
|
+
const hitResult = pickVertexWithScreenRay(
|
|
229
|
+
world,
|
|
230
|
+
screenRay,
|
|
231
|
+
scene.x,
|
|
232
|
+
scene.y,
|
|
233
|
+
extent.width,
|
|
234
|
+
extent.height,
|
|
235
|
+
{
|
|
236
|
+
limit: Number.MAX_SAFE_INTEGER,
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
const hits = Array.isArray(hitResult) ? hitResult : [];
|
|
240
|
+
const remapped = remapVertexHits(
|
|
241
|
+
hits,
|
|
242
|
+
displayX,
|
|
243
|
+
displayY,
|
|
244
|
+
mapping,
|
|
245
|
+
screenRay,
|
|
246
|
+
extent.width,
|
|
247
|
+
extent.height,
|
|
248
|
+
options?.radius,
|
|
249
|
+
);
|
|
250
|
+
return options === undefined
|
|
251
|
+
? remapped[0]
|
|
252
|
+
: options.limit === undefined
|
|
253
|
+
? remapped
|
|
254
|
+
: remapped.slice(0, options.limit);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Pick vertices on one entity using displayed-pixel radius and ordering. */
|
|
258
|
+
export function pickVertexOnEntityDisplay(
|
|
259
|
+
world: World,
|
|
260
|
+
_cameraEntity: EntityHandle,
|
|
261
|
+
displayX: number,
|
|
262
|
+
displayY: number,
|
|
263
|
+
mapping: BarrelDistortionMapping | undefined,
|
|
264
|
+
viewportWidth: number,
|
|
265
|
+
viewportHeight: number,
|
|
266
|
+
entity: EntityHandle,
|
|
267
|
+
options?: DisplayVertexPickOptions,
|
|
268
|
+
): VertexHit | VertexHit[] | undefined {
|
|
269
|
+
const scene = displayVertexQuery(displayX, displayY, mapping, viewportWidth, viewportHeight);
|
|
270
|
+
if (scene === undefined) return options === undefined ? undefined : [];
|
|
271
|
+
const screenRay = computeSceneScreenRay(scene.x, scene.y, mapping);
|
|
272
|
+
if (screenRay === undefined) return options === undefined ? undefined : [];
|
|
273
|
+
const extent =
|
|
274
|
+
mapping === undefined ? undefined : { width: mapping.width, height: mapping.height };
|
|
275
|
+
if (extent === undefined) return options === undefined ? undefined : [];
|
|
276
|
+
const hitResult = pickVertexOnEntityWithScreenRay(
|
|
277
|
+
world,
|
|
278
|
+
screenRay,
|
|
279
|
+
scene.x,
|
|
280
|
+
scene.y,
|
|
281
|
+
extent.width,
|
|
282
|
+
extent.height,
|
|
283
|
+
entity,
|
|
284
|
+
{ limit: Number.MAX_SAFE_INTEGER },
|
|
285
|
+
);
|
|
286
|
+
const hits = Array.isArray(hitResult) ? hitResult : [];
|
|
287
|
+
const remapped = remapVertexHits(
|
|
288
|
+
hits,
|
|
289
|
+
displayX,
|
|
290
|
+
displayY,
|
|
291
|
+
mapping,
|
|
292
|
+
screenRay,
|
|
293
|
+
extent.width,
|
|
294
|
+
extent.height,
|
|
295
|
+
options?.radius,
|
|
296
|
+
);
|
|
297
|
+
return options === undefined
|
|
298
|
+
? remapped[0]
|
|
299
|
+
: options.limit === undefined
|
|
300
|
+
? remapped
|
|
301
|
+
: remapped.slice(0, options.limit);
|
|
302
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
// (camera validation -> view=invert -> projection -> screenToRay -> GlobalTransform.world)
|
|
7
7
|
// lives once in pick-core.ts (AC-201).
|
|
8
8
|
|
|
9
|
+
export type { DisplayVertexPickOptions } from './display-picking';
|
|
10
|
+
export {
|
|
11
|
+
computeDisplayScreenRay,
|
|
12
|
+
displayToScenePixel,
|
|
13
|
+
pickDisplay,
|
|
14
|
+
pickVertexDisplay,
|
|
15
|
+
pickVertexOnEntityDisplay,
|
|
16
|
+
} from './display-picking';
|
|
17
|
+
|
|
9
18
|
// --- screen-to-entity ray-AABB pick (nearest pickable mesh) ---
|
|
10
19
|
export type { PickHit } from './pick';
|
|
11
20
|
export { pick } from './pick';
|
package/src/pick-core.ts
CHANGED
|
@@ -48,6 +48,39 @@ export interface ScreenRay {
|
|
|
48
48
|
readonly projectionKind: CameraProjection;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Build a screen ray from matrices captured with the accepted render frame. */
|
|
52
|
+
export function computeScreenRayFromMatrices(
|
|
53
|
+
screenX: number,
|
|
54
|
+
screenY: number,
|
|
55
|
+
viewportWidth: number,
|
|
56
|
+
viewportHeight: number,
|
|
57
|
+
viewMatrix: ArrayLike<number>,
|
|
58
|
+
projectionMatrix: ArrayLike<number>,
|
|
59
|
+
projectionKind: CameraProjection,
|
|
60
|
+
): ScreenRay | undefined {
|
|
61
|
+
if (
|
|
62
|
+
!Number.isFinite(screenX) ||
|
|
63
|
+
!Number.isFinite(screenY) ||
|
|
64
|
+
!Number.isFinite(viewportWidth) ||
|
|
65
|
+
!Number.isFinite(viewportHeight) ||
|
|
66
|
+
viewportWidth <= 0 ||
|
|
67
|
+
viewportHeight <= 0 ||
|
|
68
|
+
viewMatrix.length !== 16 ||
|
|
69
|
+
projectionMatrix.length !== 16 ||
|
|
70
|
+
!Array.from(viewMatrix).every((value) => Number.isFinite(value)) ||
|
|
71
|
+
!Array.from(projectionMatrix).every((value) => Number.isFinite(value))
|
|
72
|
+
) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
const view = mat4.create();
|
|
76
|
+
const proj = mat4.create();
|
|
77
|
+
view.set(viewMatrix);
|
|
78
|
+
proj.set(projectionMatrix);
|
|
79
|
+
const r = ray.create();
|
|
80
|
+
ray.screenToRay(r, screenX, screenY, viewportWidth, viewportHeight, view, proj, projectionKind);
|
|
81
|
+
return { ray: r, view, proj, projectionKind };
|
|
82
|
+
}
|
|
83
|
+
|
|
51
84
|
/**
|
|
52
85
|
* Build the screen-to-world ray for `cameraEntity` at the viewport-relative
|
|
53
86
|
* `(screenX, screenY)` coordinate: validate the camera component, read its world
|