@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
@@ -0,0 +1,114 @@
1
+ // pick-core.ts — shared picking skeleton for pick() / pickVertex*() (feat-20260705 M2 M0).
2
+ //
3
+ // `pick.ts` (screen-to-entity ray-AABB) and `pick-vertex.ts` (vertex-level) share a
4
+ // verbatim skeleton (F11): the `Transform.world` row type, the row reader, and the
5
+ // camera-validation → view=invert(worldMatrix) → projection-branch → screenToRay
6
+ // sequence. This module is the single source of truth for that skeleton
7
+ // (architecture-principles §2 Derive, Don't Duplicate; AC-201). pick.ts and
8
+ // pick-vertex.ts import from here rather than each maintaining their own copy.
9
+ //
10
+ // Error channel (charter P3): a `cameraEntity` that carries no `Camera` is the one
11
+ // unrecoverable precondition — `computeScreenRay` throws a structured `PickError`
12
+ // (`code: 'camera-component-missing'`). A camera entity that carries no resolvable
13
+ // `Transform.world` is a degenerate miss (no view matrix can be built) — signalled by
14
+ // a `undefined` return, which callers translate to their own miss shape
15
+ // (`undefined` for pick, `[]`/`undefined` for the vertex queries).
16
+
17
+ import type { EntityHandle, World } from '@forgeax/engine-ecs';
18
+ import { mat4, ray } from '@forgeax/engine-math';
19
+ import { Camera, type CameraProjection, cameraProjectionFromF32 } from '@forgeax/engine-render';
20
+ import { Transform } from '@forgeax/engine-scene';
21
+ import { PickError } from './pick-errors';
22
+
23
+ /**
24
+ * Read an entity's resolved world mat4 (16 column-major floats) from the
25
+ * `Transform.world` column array view (feat-20260601 D-3). Returns a fresh
26
+ * copy (the view aliases live slot bytes); `undefined` when the entity has no
27
+ * Transform / world column.
28
+ */
29
+ export function readWorldMatrix(world: World, entity: EntityHandle): Float32Array | undefined {
30
+ const result = world.get(entity, Transform);
31
+ return result.ok ? new Float32Array(result.value.world) : undefined;
32
+ }
33
+
34
+ /**
35
+ * The screen-to-world ray plus the camera matrices used to build it.
36
+ *
37
+ * - `ray` — the unprojected world-space pick ray (origin + direction).
38
+ * - `view` — `invert(camera Transform.world)`.
39
+ * - `proj` — the camera projection matrix (perspective / orthographic).
40
+ * - `projectionKind` — the resolved camera projection discriminant.
41
+ *
42
+ * `view` and `proj` are returned separately so vertex picking can build its own
43
+ * `viewProj = proj * view` for `worldToScreen` without recomputing the branch.
44
+ */
45
+ export interface ScreenRay {
46
+ readonly ray: ray.Ray;
47
+ readonly view: mat4.Mat4;
48
+ readonly proj: mat4.Mat4;
49
+ readonly projectionKind: CameraProjection;
50
+ }
51
+
52
+ /**
53
+ * Build the screen-to-world ray for `cameraEntity` at the viewport-relative
54
+ * `(screenX, screenY)` coordinate: validate the camera component, read its world
55
+ * transform, invert it to a view matrix, branch the projection on the camera
56
+ * discriminant, and unproject the coordinate into a world-space ray.
57
+ *
58
+ * @returns The `ScreenRay`, or `undefined` when the camera entity carries no
59
+ * resolvable `Transform.world` (a degenerate miss — no view matrix can be built).
60
+ * @throws {PickError} `code: 'camera-component-missing'` when `cameraEntity` has no `Camera`.
61
+ */
62
+ export function computeScreenRay(
63
+ world: World,
64
+ cameraEntity: EntityHandle,
65
+ screenX: number,
66
+ screenY: number,
67
+ viewportWidth: number,
68
+ viewportHeight: number,
69
+ ): ScreenRay | undefined {
70
+ // An invalid viewport cannot define a screen ray; refuse it before the lower
71
+ // math layer's safe fallback can become a fabricated origin hit.
72
+ if (
73
+ !Number.isFinite(viewportWidth) ||
74
+ !Number.isFinite(viewportHeight) ||
75
+ viewportWidth <= 0 ||
76
+ viewportHeight <= 0
77
+ ) {
78
+ return undefined;
79
+ }
80
+
81
+ // --- precondition: camera component present (structured error, charter P3) ---
82
+ const camRes = world.get(cameraEntity, Camera);
83
+ if (!camRes.ok) {
84
+ throw new PickError(cameraEntity as unknown as number);
85
+ }
86
+ const cam = camRes.value;
87
+
88
+ // --- camera world transform (feat-20260601 D-3: read Transform.world mat4) ---
89
+ const camWorld = readWorldMatrix(world, cameraEntity);
90
+ if (camWorld === undefined) {
91
+ // A camera entity without a Transform cannot define a view matrix; treat the
92
+ // degenerate case as a miss rather than fabricating an identity view (no spurious hit).
93
+ return undefined;
94
+ }
95
+
96
+ // --- view = invert(camera world mat4) ---
97
+ const view = mat4.create();
98
+ mat4.invert(view, camWorld as unknown as mat4.Mat4Like);
99
+
100
+ // --- projection: branch on the camera discriminant (research Finding 5a) ---
101
+ const projectionKind = cameraProjectionFromF32(cam.projection);
102
+ const proj = mat4.create();
103
+ if (projectionKind === 'orthographic') {
104
+ mat4.orthographic(proj, cam.left, cam.right, cam.bottom, cam.top, cam.near, cam.far);
105
+ } else {
106
+ mat4.perspective(proj, cam.fov, cam.aspect, cam.near, cam.far);
107
+ }
108
+
109
+ // --- screen -> world ray (two-point unproject; clamp + NaN/Inf sanitized inside) ---
110
+ const r = ray.create();
111
+ ray.screenToRay(r, screenX, screenY, viewportWidth, viewportHeight, view, proj, projectionKind);
112
+
113
+ return { ray: r, view, proj, projectionKind };
114
+ }
@@ -0,0 +1,69 @@
1
+ // @forgeax/engine-picking — pick error model (feat-20260529-picking-raycasting-screen-to-entity M3 / w9).
2
+ //
3
+ // PickError code owner + derived PickErrorCode projection. The screen-to-entity
4
+ // `pick(...)` free function returns `undefined` for the recoverable miss path (no ray
5
+ // hit) and surfaces a structured PickError ONLY for the one unrecoverable precondition
6
+ // failure: the supplied cameraEntity does not hold a `Camera` component, so no view /
7
+ // projection matrix can be built. Separating the error channel (PickError) from the
8
+ // normal miss channel (undefined) lets AI users branch with `if (hit)` for the common
9
+ // case and inspect `.code` for the precondition failure (charter P3).
10
+ //
11
+ // D-1: a NEW closed union rather than reusing RuntimeErrorCode. RuntimeErrorCode's 8
12
+ // members are all render / skin / shadow domain; folding a picking precondition into it
13
+ // would dilute its semantic focus. A dedicated single-member union keeps the picking
14
+ // error surface cohesive and additively evolvable (AGENTS.md Error model minor add-only).
15
+ //
16
+ // Related: requirements AC-11 (structured error signal) + AC-13 (closed-union SSOT);
17
+ // plan-strategy D-1; plan-tasks.json w9 acceptanceCheck; charter P3.
18
+
19
+ /**
20
+ * Detail for the `PickError` camera-component-missing code.
21
+ *
22
+ * Carries the offending camera entity (packed `Entity` u32) so AI consumers can read
23
+ * `.detail.cameraEntity` by property access (charter P4) — no string parsing of the
24
+ * human-facing message.
25
+ */
26
+ export interface PickCameraMissingDetail {
27
+ readonly cameraEntity: number;
28
+ }
29
+
30
+ /**
31
+ * Structured error for the picking precondition failure: the supplied `cameraEntity`
32
+ * does not hold a `Camera` component, so `pick()` cannot build the view / projection
33
+ * matrices needed to unproject the screen coordinate.
34
+ *
35
+ * Three-field structured surface per the AGENTS.md error model + `.detail`:
36
+ * - `.code = 'camera-component-missing'` (closed `PickErrorCode`)
37
+ * - `.expected` — the expected precondition (camera entity carries a `Camera`)
38
+ * - `.hint` — an actionable `world.set` recovery directive
39
+ * - `.detail = { cameraEntity }` — the offending entity (charter P4)
40
+ *
41
+ * Surfaced (not the normal miss path): a no-hit ray returns `undefined` from `pick()`;
42
+ * this error is reserved for the unrecoverable precondition (charter P3 explicit failure,
43
+ * separate channel from the recoverable miss).
44
+ */
45
+ export class PickError extends Error {
46
+ readonly code = 'camera-component-missing';
47
+ readonly expected: string;
48
+ readonly hint: string;
49
+ readonly detail: PickCameraMissingDetail;
50
+
51
+ constructor(cameraEntity: number) {
52
+ const expected = 'cameraEntity holds a Camera component';
53
+ const hint =
54
+ `cameraEntity ${cameraEntity} has no Camera component; spawn or attach one before ` +
55
+ 'picking, e.g. world.set(cameraEntity, Camera, { fov: Math.PI / 4, aspect, near, far })';
56
+ super(`pick: cameraEntity ${cameraEntity} has no Camera component`);
57
+ this.name = 'PickError';
58
+ this.expected = expected;
59
+ this.hint = hint;
60
+ this.detail = { cameraEntity };
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Closed union of picking precondition error codes, derived from the PickError owner.
66
+ *
67
+ * AI users perform exhaustive `switch (err.code)` without a default; TS guards completeness.
68
+ */
69
+ export type PickErrorCode = PickError['code'];
@@ -0,0 +1,157 @@
1
+ // pick-tile.ts - cell-level Tilemap query (feat-20260608 M0 baseline rebuild).
2
+ //
3
+ // `pickTile(world, tilemapEntity, worldX, worldY)` is a free function (not a
4
+ // method on `World` -- charter F1 single-import barrel from
5
+ // `@forgeax/engine-runtime`). It converts world-space coordinates into the
6
+ // Tilemap's local cell grid, walks every TileLayer that ChildOf-s the
7
+ // supplied tilemap, and returns the FIRST non-zero tile id seen when scanning
8
+ // layers in DESCENDING `layerOrder` (highest layer drawn on top wins).
9
+ //
10
+ // charter mapping:
11
+ // - P3 explicit failure as value: out-of-bounds and "every layer empty"
12
+ // resolve to `Result.ok(null)` -- never throws or fires onError. Dead
13
+ // handles and live entities without Tilemap are separate structural
14
+ // failures; the `PickTileError` union is closed to two variants and stays
15
+ // a runtime-only type (not exported through `@forgeax/engine-types`, since
16
+ // pickTile is a runtime-only system).
17
+ // - P4 consistent abstraction: matches the `pick(world, ...)` raycast
18
+ // surface in pick.ts -- free function, world+entity input, structured
19
+ // error union, hit-or-null return.
20
+ // - F1 single-import: exported from `@forgeax/engine-picking`'s barrel.
21
+ //
22
+ // Anchors: requirements §integration-points (engine-runtime pickTile);
23
+ // plan-tasks m0-t8; plan-strategy §D-5 file-by-file ECS API adaptation
24
+ // (World-owned Query pattern from render-system-extract.ts).
25
+
26
+ import { Entity, type EntityHandle, type World } from '@forgeax/engine-ecs';
27
+ import { mat4, vec3 } from '@forgeax/engine-math';
28
+ import { TileLayer, Tilemap } from '@forgeax/engine-render/authoring';
29
+ import { ChildOf, Transform } from '@forgeax/engine-scene';
30
+ import { err, ok, type Result } from '@forgeax/engine-types';
31
+
32
+ /**
33
+ * Closed error union for `pickTile` (charter P3 + P4). Two variants cover the
34
+ * structural-break paths; "ray hit nothing" is a value (`Result.ok(null)`),
35
+ * not an error.
36
+ */
37
+ export type PickTileError =
38
+ | { readonly code: 'tilemap-not-found'; readonly tilemapEntity: EntityHandle }
39
+ | { readonly code: 'tilemap-component-missing'; readonly tilemapEntity: EntityHandle };
40
+
41
+ /**
42
+ * Successful picking outcome. Returned through `Result.ok`; a `null` value
43
+ * means the query landed in-bounds but every layer at that cell was empty
44
+ * (or the point was outside the tilemap world bounds).
45
+ */
46
+ export interface PickTileHit {
47
+ readonly layerEntity: EntityHandle;
48
+ readonly cellX: number;
49
+ readonly cellY: number;
50
+ readonly tileId: number;
51
+ }
52
+
53
+ /**
54
+ * Find the topmost non-zero tile under `(worldX, worldY)` for a given
55
+ * Tilemap entity, walking child TileLayer entities in DESCENDING
56
+ * `layerOrder`.
57
+ *
58
+ * @returns
59
+ * - `Result.ok(PickTileHit)` for a non-zero cell on some layer.
60
+ * - `Result.ok(null)` for an empty cell or out-of-bounds query.
61
+ * - `Result.err({ code: 'tilemap-not-found' })` for a dead handle.
62
+ * - `Result.err({ code: 'tilemap-component-missing' })` for a live entity
63
+ * without a Tilemap component.
64
+ *
65
+ * The caller must propagate the World so `Transform.world` is current. A
66
+ * singular world transform follows `mat4.invert`'s deterministic identity
67
+ * fallback, which keeps this error union structural rather than adding a
68
+ * third diagnostic arm.
69
+ */
70
+ export function pickTile(
71
+ world: World,
72
+ tilemapEntity: EntityHandle,
73
+ worldX: number,
74
+ worldY: number,
75
+ ): Result<PickTileHit | null, PickTileError> {
76
+ const entityResult = world.get(tilemapEntity, Entity);
77
+ if (!entityResult.ok) {
78
+ return err({ code: 'tilemap-not-found', tilemapEntity });
79
+ }
80
+
81
+ const tilemapResult = world.get(tilemapEntity, Tilemap);
82
+ if (!tilemapResult.ok) {
83
+ return err({ code: 'tilemap-component-missing', tilemapEntity });
84
+ }
85
+ const tilemap = tilemapResult.value;
86
+ const cols = tilemap.cols;
87
+ const rows = tilemap.rows;
88
+ // feat-20260709 M3: tileSize is one inline array<f32,2> column; the
89
+ // world.get read path materialises it as a Float32Array ([width, height]).
90
+ const tileSizeX = tilemap.tileSize[0] ?? 1;
91
+ const tileSizeY = tilemap.tileSize[1] ?? 1;
92
+
93
+ // Tilemap entities without a Transform retain the origin-default path. For
94
+ // transformed maps, the full propagated affine inverse is the only correct
95
+ // way to recover local cell coordinates under rotation and non-uniform scale.
96
+ let localX = worldX;
97
+ let localY = worldY;
98
+ const transformResult = world.get(tilemapEntity, Transform);
99
+ if (transformResult.ok) {
100
+ const w = transformResult.value.world;
101
+ if (w !== undefined && w.length >= 16) {
102
+ const local = mat4.transformPoint(vec3.create(), mat4.invert(mat4.create(), w), [
103
+ worldX,
104
+ worldY,
105
+ 0,
106
+ ]);
107
+ localX = local[0] ?? 0;
108
+ localY = local[1] ?? 0;
109
+ }
110
+ }
111
+
112
+ if (tileSizeX <= 0 || tileSizeY <= 0) return ok(null);
113
+ if (localX < 0 || localY < 0) return ok(null);
114
+
115
+ const cellX = Math.floor(localX / tileSizeX);
116
+ const cellY = Math.floor(localY / tileSizeY);
117
+ if (cellX < 0 || cellY < 0 || cellX >= cols || cellY >= rows) return ok(null);
118
+
119
+ // Collect every TileLayer ChildOf-ing the supplied Tilemap entity, with
120
+ // its layerOrder + tile array snapshot through one row iterator.
121
+ type LayerInfo = {
122
+ readonly entity: EntityHandle;
123
+ readonly tiles: ArrayLike<number>;
124
+ readonly layerOrder: number;
125
+ };
126
+ const layers: LayerInfo[] = [];
127
+ const layerQuery = world.query({ read: [TileLayer, ChildOf] }).unwrap();
128
+ for (const row of layerQuery) {
129
+ const layerEntity = row.entity;
130
+ const parent = row.get(ChildOf).parent;
131
+ if ((parent as unknown as number) !== (tilemapEntity as unknown as number)) continue;
132
+ const layerData = world.get(layerEntity, TileLayer);
133
+ if (!layerData.ok) continue;
134
+ layers.push({
135
+ entity: layerEntity,
136
+ tiles: layerData.value.tiles as ArrayLike<number>,
137
+ layerOrder: row.get(TileLayer).layerOrder,
138
+ });
139
+ }
140
+
141
+ layers.sort((a, b) => b.layerOrder - a.layerOrder);
142
+
143
+ const cellIndex = cellY * cols + cellX;
144
+ for (const layer of layers) {
145
+ if (cellIndex >= layer.tiles.length) continue;
146
+ const tileId = layer.tiles[cellIndex] ?? 0;
147
+ if (tileId !== 0) {
148
+ return ok({
149
+ layerEntity: layer.entity,
150
+ cellX,
151
+ cellY,
152
+ tileId,
153
+ });
154
+ }
155
+ }
156
+ return ok(null);
157
+ }