@forgeax/engine-picking 0.1.33 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-picking",
3
- "version": "0.1.33",
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.33",
26
- "@forgeax/engine-ecs": "0.1.33",
27
- "@forgeax/engine-math": "0.1.33",
28
- "@forgeax/engine-render": "0.1.33",
29
- "@forgeax/engine-runtime": "0.1.33",
30
- "@forgeax/engine-scene": "0.1.33",
31
- "@forgeax/engine-types": "0.1.33"
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.33",
35
- "@forgeax/engine-shader": "0.1.33"
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, instanceCollections };
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: { collectionId: collection.collectionId } },
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, { instances: scene.instanceCollections });
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 an explicit Instances resolver is absent', () => {
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