@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
package/src/pick.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// pick.ts — screen-to-entity raycast (feat-20260529-picking-raycasting-screen-to-entity M3 / w13).
|
|
2
|
+
//
|
|
3
|
+
// `pick(world, cameraEntity, screenX, screenY, viewportWidth, viewportHeight)`
|
|
4
|
+
// is a free function (NOT `world.pick()`; requirements hard constraint): it unprojects
|
|
5
|
+
// a viewport-relative screen coordinate into a world-space ray through the supplied
|
|
6
|
+
// camera, walks every renderable archetype, ray-AABB tests each pickable mesh's
|
|
7
|
+
// world-space bounding box, and returns the NEAREST `PickHit` (or `undefined` on miss).
|
|
8
|
+
//
|
|
9
|
+
// Mesh AABB source (feat-20260614 M8, D-15/D-18): the ray-AABB test needs each
|
|
10
|
+
// mesh's local-space `MeshAsset.aabb`, resolved from the entity's `MeshFilter`
|
|
11
|
+
// handle via `world.sharedRefs.resolve<MeshAsset>(handle)` (two-tier builtin /
|
|
12
|
+
// world.sharedRefs dispatch). The registry no longer holds handles, so `pick`
|
|
13
|
+
// takes no `AssetRegistry` -- it resolves entirely World-side. Keeping `pick` a
|
|
14
|
+
// free function (rather than a `World` method) preserves the layering: `World`
|
|
15
|
+
// (engine-ecs) stays asset-free; the picking glue lives in the runtime package
|
|
16
|
+
// alongside the renderer.
|
|
17
|
+
//
|
|
18
|
+
// Error channel split (charter P3): the single unrecoverable precondition —
|
|
19
|
+
// `cameraEntity` carries no `Camera` — throws a structured `PickError`
|
|
20
|
+
// (`code: 'camera-component-missing'`); the ordinary "ray hit nothing" outcome returns
|
|
21
|
+
// `undefined`. AI users branch with `if (hit)` for the common case and only handle
|
|
22
|
+
// `PickError` where they cannot guarantee the camera entity is well-formed.
|
|
23
|
+
//
|
|
24
|
+
// Transform source (feat-20260601 D-3): per entity (camera + candidates) read the
|
|
25
|
+
// single resolved `Transform.world` mat4 written by `propagateTransforms` -- the
|
|
26
|
+
// GlobalTransform/Transform fallback double-track is retired (the world column
|
|
27
|
+
// always exists on a Transform-bearing entity). The camera view is
|
|
28
|
+
// `mat4.invert(Transform.world)`; the candidate AABB is the local AABB
|
|
29
|
+
// transformed by `Transform.world` directly. The world mat4 is read through the
|
|
30
|
+
// M1 row-level access, zero `{}` materialization.
|
|
31
|
+
//
|
|
32
|
+
// Related: requirements in-scope #5/#6/#7 + AC-05..AC-11; plan-strategy D-3 / D-6 / 5.3;
|
|
33
|
+
// research Finding 4 (local->world AABB) + Finding 5 (entity-id via query rows).
|
|
34
|
+
|
|
35
|
+
import type { EntityHandle, World } from '@forgeax/engine-ecs';
|
|
36
|
+
import { box3, ray, type Vec3Like, vec3 } from '@forgeax/engine-math';
|
|
37
|
+
import { MeshFilter, MeshRenderer } from '@forgeax/engine-render';
|
|
38
|
+
import { Transform } from '@forgeax/engine-scene';
|
|
39
|
+
import type { MeshAsset } from '@forgeax/engine-types';
|
|
40
|
+
import { toShared } from '@forgeax/engine-types';
|
|
41
|
+
import { resolvePickingAsset } from './asset-port';
|
|
42
|
+
import { computeScreenRay, readWorldMatrix } from './pick-core';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Result of a successful screen-to-entity pick.
|
|
46
|
+
*
|
|
47
|
+
* Minimal three-field surface (D-6):
|
|
48
|
+
* - `entity` — the picked `Entity` (packed u32 handle, ready for `world.get` / `world.set`)
|
|
49
|
+
* - `point` — the world-space ray/AABB entry point (`Vec3Like`; a 3-element array)
|
|
50
|
+
* - `distance` — the entry distance along the ray from the camera (>= 0)
|
|
51
|
+
*
|
|
52
|
+
* No `face` / `uv` / `normal` fields: AABB picking has no triangle resolution, so those
|
|
53
|
+
* would be a lie. A future mesh-precise pick spin-off owns them (requirements OOS).
|
|
54
|
+
*/
|
|
55
|
+
export interface PickHit {
|
|
56
|
+
readonly entity: EntityHandle;
|
|
57
|
+
readonly point: Vec3Like;
|
|
58
|
+
readonly distance: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Raycast from a viewport-relative screen coordinate into the world and return the
|
|
63
|
+
* nearest pickable mesh entity whose world-space AABB the ray enters.
|
|
64
|
+
*
|
|
65
|
+
* @param world The ECS world holding the camera + candidate mesh entities (and the
|
|
66
|
+
* per-World SharedRefStore that owns each `MeshAsset` and its local-space `aabb`).
|
|
67
|
+
* @param cameraEntity The entity carrying the `Camera` component (and a Transform).
|
|
68
|
+
* @param screenX Horizontal pixel coordinate relative to the viewport top-left (y-down).
|
|
69
|
+
* @param screenY Vertical pixel coordinate.
|
|
70
|
+
* @param viewportWidth Viewport width in pixels.
|
|
71
|
+
* @param viewportHeight Viewport height in pixels.
|
|
72
|
+
* @returns The nearest `PickHit`, or `undefined` when the ray hits nothing.
|
|
73
|
+
* @throws {PickError} `code: 'camera-component-missing'` when `cameraEntity` has no `Camera`.
|
|
74
|
+
*/
|
|
75
|
+
export function pick(
|
|
76
|
+
world: World,
|
|
77
|
+
cameraEntity: EntityHandle,
|
|
78
|
+
screenX: number,
|
|
79
|
+
screenY: number,
|
|
80
|
+
viewportWidth: number,
|
|
81
|
+
viewportHeight: number,
|
|
82
|
+
): PickHit | undefined {
|
|
83
|
+
// --- camera validation + view/projection + screen->world ray (pick-core skeleton) ---
|
|
84
|
+
// Throws PickError('camera-component-missing') when cameraEntity has no Camera;
|
|
85
|
+
// returns undefined when the camera has no resolvable Transform.world (degenerate miss).
|
|
86
|
+
const screenRay = computeScreenRay(
|
|
87
|
+
world,
|
|
88
|
+
cameraEntity,
|
|
89
|
+
screenX,
|
|
90
|
+
screenY,
|
|
91
|
+
viewportWidth,
|
|
92
|
+
viewportHeight,
|
|
93
|
+
);
|
|
94
|
+
if (screenRay === undefined) return undefined;
|
|
95
|
+
const r = screenRay.ray;
|
|
96
|
+
|
|
97
|
+
const query = world.query({ read: [Transform, MeshFilter, MeshRenderer] }).unwrap();
|
|
98
|
+
|
|
99
|
+
const worldAabb = box3.create();
|
|
100
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
101
|
+
let bestEntity: EntityHandle | undefined;
|
|
102
|
+
|
|
103
|
+
for (const row of query) {
|
|
104
|
+
const assetHandleRaw = Math.round(row.get(MeshFilter).assetHandle as number);
|
|
105
|
+
if (assetHandleRaw === 0) continue;
|
|
106
|
+
const meshRes = resolvePickingAsset<MeshAsset>(world, toShared<'MeshAsset'>(assetHandleRaw));
|
|
107
|
+
if (!meshRes.ok) continue;
|
|
108
|
+
const localAabb = meshRes.value.aabb;
|
|
109
|
+
if (localAabb === undefined) continue;
|
|
110
|
+
// Inverted-infinity empty box (mesh without positions): not pickable.
|
|
111
|
+
if ((localAabb[0] as number) > (localAabb[3] as number)) continue;
|
|
112
|
+
|
|
113
|
+
// read the packed Entity for this row from the essential id=0 Entity
|
|
114
|
+
// column (`self` field); the column exists on every archetype.
|
|
115
|
+
const entity = row.entity;
|
|
116
|
+
|
|
117
|
+
// local AABB -> world AABB using the resolved Transform.world mat4
|
|
118
|
+
// directly (feat-20260601 D-3: no compose from decomposed TRS).
|
|
119
|
+
const entityWorld = readWorldMatrix(world, entity);
|
|
120
|
+
if (entityWorld === undefined) continue;
|
|
121
|
+
box3.transformBox3(
|
|
122
|
+
worldAabb,
|
|
123
|
+
localAabb,
|
|
124
|
+
entityWorld as unknown as Parameters<typeof box3.transformBox3>[2],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const result = ray.rayAabbIntersects(r, worldAabb);
|
|
128
|
+
if (result.hit && result.tmin < bestDistance) {
|
|
129
|
+
bestDistance = result.tmin;
|
|
130
|
+
bestEntity = entity;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (bestEntity === undefined) return undefined;
|
|
135
|
+
|
|
136
|
+
// entry point = origin + direction * tmin
|
|
137
|
+
const origin = vec3.create();
|
|
138
|
+
const dir = vec3.create();
|
|
139
|
+
ray.getOrigin(origin, r);
|
|
140
|
+
ray.getDirection(dir, r);
|
|
141
|
+
const point = vec3.create(
|
|
142
|
+
(origin[0] as number) + (dir[0] as number) * bestDistance,
|
|
143
|
+
(origin[1] as number) + (dir[1] as number) * bestDistance,
|
|
144
|
+
(origin[2] as number) + (dir[2] as number) * bestDistance,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return { entity: bestEntity, point, distance: bestDistance };
|
|
148
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { EntityHandle, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import type { ray } from '@forgeax/engine-math';
|
|
3
|
+
import { computeScreenRay } from './pick-core';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Unproject a viewport pixel into a world-space ray.
|
|
7
|
+
*
|
|
8
|
+
* `screenX`/`screenY` use the canvas convention: top-left origin, y down.
|
|
9
|
+
* The caller must run `propagateTransforms(world)` for the current frame first.
|
|
10
|
+
* A camera without a resolvable Transform returns `undefined`; a missing
|
|
11
|
+
* Camera component throws the package's structured `PickError`.
|
|
12
|
+
*/
|
|
13
|
+
export function viewportToWorld(
|
|
14
|
+
world: World,
|
|
15
|
+
cameraEntity: EntityHandle,
|
|
16
|
+
screenX: number,
|
|
17
|
+
screenY: number,
|
|
18
|
+
viewportWidth: number,
|
|
19
|
+
viewportHeight: number,
|
|
20
|
+
): ray.Ray | undefined {
|
|
21
|
+
return computeScreenRay(world, cameraEntity, screenX, screenY, viewportWidth, viewportHeight)
|
|
22
|
+
?.ray;
|
|
23
|
+
}
|