@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.
Files changed (51) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +168 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/glyph-text-pick.test.d.ts +2 -0
  5. package/dist/__tests__/glyph-text-pick.test.d.ts.map +1 -0
  6. package/dist/__tests__/pick-errors.test-d.d.ts +2 -0
  7. package/dist/__tests__/pick-errors.test-d.d.ts.map +1 -0
  8. package/dist/__tests__/pick-errors.test.d.ts +2 -0
  9. package/dist/__tests__/pick-errors.test.d.ts.map +1 -0
  10. package/dist/__tests__/pick-tile.test.d.ts +2 -0
  11. package/dist/__tests__/pick-tile.test.d.ts.map +1 -0
  12. package/dist/__tests__/pick-vertex.unit.test.d.ts +2 -0
  13. package/dist/__tests__/pick-vertex.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/pick.test.d.ts +2 -0
  15. package/dist/__tests__/pick.test.d.ts.map +1 -0
  16. package/dist/__tests__/visibility-regression.integration.test.d.ts +2 -0
  17. package/dist/__tests__/visibility-regression.integration.test.d.ts.map +1 -0
  18. package/dist/asset-port.d.ts +6 -0
  19. package/dist/asset-port.d.ts.map +1 -0
  20. package/dist/index.d.ts +9 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.mjs +444 -0
  23. package/dist/index.mjs.map +1 -0
  24. package/dist/pick-core.d.ts +39 -0
  25. package/dist/pick-core.d.ts.map +1 -0
  26. package/dist/pick-errors.d.ts +39 -0
  27. package/dist/pick-errors.d.ts.map +1 -0
  28. package/dist/pick-tile.d.ts +44 -0
  29. package/dist/pick-tile.d.ts.map +1 -0
  30. package/dist/pick-vertex.d.ts +87 -0
  31. package/dist/pick-vertex.d.ts.map +1 -0
  32. package/dist/pick.d.ts +34 -0
  33. package/dist/pick.d.ts.map +1 -0
  34. package/dist/viewport-to-world.d.ts +12 -0
  35. package/dist/viewport-to-world.d.ts.map +1 -0
  36. package/package.json +66 -0
  37. package/src/__tests__/glyph-text-pick.test.ts +131 -0
  38. package/src/__tests__/pick-errors.test-d.ts +66 -0
  39. package/src/__tests__/pick-errors.test.ts +71 -0
  40. package/src/__tests__/pick-tile.test.ts +239 -0
  41. package/src/__tests__/pick-vertex.unit.test.ts +1588 -0
  42. package/src/__tests__/pick.test.ts +525 -0
  43. package/src/__tests__/visibility-regression.integration.test.ts +74 -0
  44. package/src/asset-port.ts +13 -0
  45. package/src/index.ts +21 -0
  46. package/src/pick-core.ts +114 -0
  47. package/src/pick-errors.ts +69 -0
  48. package/src/pick-tile.ts +157 -0
  49. package/src/pick-vertex.ts +593 -0
  50. package/src/pick.ts +148 -0
  51. package/src/viewport-to-world.ts +23 -0
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
+ }