@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,39 @@
1
+ /**
2
+ * Detail for the `PickError` camera-component-missing code.
3
+ *
4
+ * Carries the offending camera entity (packed `Entity` u32) so AI consumers can read
5
+ * `.detail.cameraEntity` by property access (charter P4) — no string parsing of the
6
+ * human-facing message.
7
+ */
8
+ export interface PickCameraMissingDetail {
9
+ readonly cameraEntity: number;
10
+ }
11
+ /**
12
+ * Structured error for the picking precondition failure: the supplied `cameraEntity`
13
+ * does not hold a `Camera` component, so `pick()` cannot build the view / projection
14
+ * matrices needed to unproject the screen coordinate.
15
+ *
16
+ * Three-field structured surface per the AGENTS.md error model + `.detail`:
17
+ * - `.code = 'camera-component-missing'` (closed `PickErrorCode`)
18
+ * - `.expected` — the expected precondition (camera entity carries a `Camera`)
19
+ * - `.hint` — an actionable `world.set` recovery directive
20
+ * - `.detail = { cameraEntity }` — the offending entity (charter P4)
21
+ *
22
+ * Surfaced (not the normal miss path): a no-hit ray returns `undefined` from `pick()`;
23
+ * this error is reserved for the unrecoverable precondition (charter P3 explicit failure,
24
+ * separate channel from the recoverable miss).
25
+ */
26
+ export declare class PickError extends Error {
27
+ readonly code = "camera-component-missing";
28
+ readonly expected: string;
29
+ readonly hint: string;
30
+ readonly detail: PickCameraMissingDetail;
31
+ constructor(cameraEntity: number);
32
+ }
33
+ /**
34
+ * Closed union of picking precondition error codes, derived from the PickError owner.
35
+ *
36
+ * AI users perform exhaustive `switch (err.code)` without a default; TS guards completeness.
37
+ */
38
+ export type PickErrorCode = PickError['code'];
39
+ //# sourceMappingURL=pick-errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pick-errors.d.ts","sourceRoot":"","sources":["../src/pick-errors.ts"],"names":[],"mappings":"AAkBA;;;;;;GAMG;AACH,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;;;;;;;;;GAcG;AACH,qBAAa,SAAU,SAAQ,KAAK;IAClC,QAAQ,CAAC,IAAI,8BAA8B;IAC3C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,uBAAuB,CAAC;gBAE7B,YAAY,EAAE,MAAM;CAWjC;AAED;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC"}
@@ -0,0 +1,44 @@
1
+ import { type EntityHandle, type World } from '@forgeax/engine-ecs';
2
+ import { type Result } from '@forgeax/engine-types';
3
+ /**
4
+ * Closed error union for `pickTile` (charter P3 + P4). Two variants cover the
5
+ * structural-break paths; "ray hit nothing" is a value (`Result.ok(null)`),
6
+ * not an error.
7
+ */
8
+ export type PickTileError = {
9
+ readonly code: 'tilemap-not-found';
10
+ readonly tilemapEntity: EntityHandle;
11
+ } | {
12
+ readonly code: 'tilemap-component-missing';
13
+ readonly tilemapEntity: EntityHandle;
14
+ };
15
+ /**
16
+ * Successful picking outcome. Returned through `Result.ok`; a `null` value
17
+ * means the query landed in-bounds but every layer at that cell was empty
18
+ * (or the point was outside the tilemap world bounds).
19
+ */
20
+ export interface PickTileHit {
21
+ readonly layerEntity: EntityHandle;
22
+ readonly cellX: number;
23
+ readonly cellY: number;
24
+ readonly tileId: number;
25
+ }
26
+ /**
27
+ * Find the topmost non-zero tile under `(worldX, worldY)` for a given
28
+ * Tilemap entity, walking child TileLayer entities in DESCENDING
29
+ * `layerOrder`.
30
+ *
31
+ * @returns
32
+ * - `Result.ok(PickTileHit)` for a non-zero cell on some layer.
33
+ * - `Result.ok(null)` for an empty cell or out-of-bounds query.
34
+ * - `Result.err({ code: 'tilemap-not-found' })` for a dead handle.
35
+ * - `Result.err({ code: 'tilemap-component-missing' })` for a live entity
36
+ * without a Tilemap component.
37
+ *
38
+ * The caller must propagate the World so `Transform.world` is current. A
39
+ * singular world transform follows `mat4.invert`'s deterministic identity
40
+ * fallback, which keeps this error union structural rather than adding a
41
+ * third diagnostic arm.
42
+ */
43
+ export declare function pickTile(world: World, tilemapEntity: EntityHandle, worldX: number, worldY: number): Result<PickTileHit | null, PickTileError>;
44
+ //# sourceMappingURL=pick-tile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pick-tile.d.ts","sourceRoot":"","sources":["../src/pick-tile.ts"],"names":[],"mappings":"AAyBA,OAAO,EAAU,KAAK,YAAY,EAAE,KAAK,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAI5E,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAE7D;;;;GAIG;AACH,MAAM,MAAM,aAAa,GACrB;IAAE,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,aAAa,EAAE,YAAY,CAAA;CAAE,GAC5E;IAAE,QAAQ,CAAC,IAAI,EAAE,2BAA2B,CAAC;IAAC,QAAQ,CAAC,aAAa,EAAE,YAAY,CAAA;CAAE,CAAC;AAEzF;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,WAAW,EAAE,YAAY,CAAC;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,QAAQ,CACtB,KAAK,EAAE,KAAK,EACZ,aAAa,EAAE,YAAY,EAC3B,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,GACb,MAAM,CAAC,WAAW,GAAG,IAAI,EAAE,aAAa,CAAC,CAkF3C"}
@@ -0,0 +1,87 @@
1
+ import type { EntityHandle, World } from '@forgeax/engine-ecs';
2
+ import { type Vec3Like } from '@forgeax/engine-math';
3
+ /**
4
+ * Result of a successful vertex pick: the mesh vertex nearest to the
5
+ * supplied screen-space coordinate.
6
+ *
7
+ * Field set:
8
+ * - `entity` — the entity this vertex belongs to.
9
+ * - `vertexIndex` — index into the vertex position buffer (0-based).
10
+ * - `worldPos` — world-space position of the vertex (Vec3Like; rest-pose when
11
+ * deformed=true). D-7: Vec3Like avoids math brand cast lint in
12
+ * the runtime package.
13
+ * - `screenDist` — screen-space pixel distance from the query coordinate to the
14
+ * projected vertex position (non-negative).
15
+ * - `worldDist` — perpendicular 3D distance from the vertex world position to
16
+ * the pick ray (non-negative). Orthogonal counterpart to screenDist.
17
+ * - `deformed` — true when the mesh is skinned (skinIndex + skinWeight attributes
18
+ * both present), indicating worldPos reflects rest-pose, not GPU
19
+ * skinning output.
20
+ */
21
+ export interface VertexHit {
22
+ readonly entity: EntityHandle;
23
+ readonly vertexIndex: number;
24
+ readonly worldPos: Vec3Like;
25
+ readonly screenDist: number;
26
+ readonly worldDist: number;
27
+ readonly deformed: boolean;
28
+ }
29
+ /**
30
+ * Query the nearest vertex on a single entity.
31
+ *
32
+ * Without options: returns `VertexHit | undefined` (nearest hit, or `undefined` on miss).
33
+ *
34
+ * @param world The ECS world (propagateTransforms must have been called this frame).
35
+ * @param cameraEntity Entity carrying the Camera component (and Transform).
36
+ * @param screenX Horizontal pixel coordinate (viewport top-left, y-down).
37
+ * @param screenY Vertical pixel coordinate.
38
+ * @param viewportWidth Viewport width in pixels.
39
+ * @param viewportHeight Viewport height in pixels.
40
+ * @param entity The mesh entity to query (must carry MeshFilter + MeshRenderer).
41
+ * @returns The nearest `VertexHit`, or `undefined` when nothing is hit.
42
+ * @throws {PickError} `code: 'camera-component-missing'` when cameraEntity has no Camera.
43
+ */
44
+ export declare function pickVertexOnEntity(world: World, cameraEntity: EntityHandle, screenX: number, screenY: number, viewportWidth: number, viewportHeight: number, entity: EntityHandle): VertexHit | undefined;
45
+ /**
46
+ * Query up to `limit` nearest vertices on a single entity.
47
+ *
48
+ * With `{ limit }`: returns `VertexHit[]` sorted by `screenDist` ascending.
49
+ *
50
+ * @param options.limit Maximum number of candidates to return (returns all available
51
+ * vertices when limit exceeds the hit count).
52
+ * @returns Sorted array of `VertexHit` (empty on miss).
53
+ */
54
+ export declare function pickVertexOnEntity(world: World, cameraEntity: EntityHandle, screenX: number, screenY: number, viewportWidth: number, viewportHeight: number, entity: EntityHandle, options: {
55
+ limit: number;
56
+ }): VertexHit[];
57
+ /**
58
+ * Query the nearest vertex across all pickable mesh entities in the world.
59
+ *
60
+ * Without options: returns `VertexHit | undefined` (globally nearest, or `undefined` on miss).
61
+ *
62
+ * Walks all renderable archetypes, does an AABB coarse cull (R-2), then calls
63
+ * `pickVertexOnEntity` on each ray-intersecting entity. Builtin meshes without AABB
64
+ * fall through to walk-all-vertices (AC-07).
65
+ *
66
+ * @param world The ECS world (propagateTransforms must have been called this frame).
67
+ * @param cameraEntity Entity carrying the Camera component (and Transform).
68
+ * @param screenX Horizontal pixel coordinate (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 globally nearest `VertexHit`, or `undefined` when nothing is hit.
73
+ * @throws {PickError} `code: 'camera-component-missing'` when cameraEntity has no Camera.
74
+ */
75
+ export declare function pickVertex(world: World, cameraEntity: EntityHandle, screenX: number, screenY: number, viewportWidth: number, viewportHeight: number): VertexHit | undefined;
76
+ /**
77
+ * Query up to `limit` nearest vertices across all pickable mesh entities.
78
+ *
79
+ * With `{ limit }`: returns `VertexHit[]` globally sorted by `screenDist` ascending.
80
+ *
81
+ * @param options.limit Maximum number of candidates to return.
82
+ * @returns Sorted array of `VertexHit` (empty on miss).
83
+ */
84
+ export declare function pickVertex(world: World, cameraEntity: EntityHandle, screenX: number, screenY: number, viewportWidth: number, viewportHeight: number, options: {
85
+ limit: number;
86
+ }): VertexHit[];
87
+ //# sourceMappingURL=pick-vertex.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pick-vertex.d.ts","sourceRoot":"","sources":["../src/pick-vertex.ts"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,EAAa,KAAK,QAAQ,EAAc,MAAM,sBAAsB,CAAC;AAU5E;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;CAC5B;AAqTD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,KAAK,EACZ,YAAY,EAAE,YAAY,EAC1B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,EACtB,MAAM,EAAE,YAAY,GACnB,SAAS,GAAG,SAAS,CAAC;AAEzB;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,KAAK,EACZ,YAAY,EAAE,YAAY,EAC1B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,EACtB,MAAM,EAAE,YAAY,EACpB,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,GACzB,SAAS,EAAE,CAAC;AAmCf;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,UAAU,CACxB,KAAK,EAAE,KAAK,EACZ,YAAY,EAAE,YAAY,EAC1B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,GACrB,SAAS,GAAG,SAAS,CAAC;AAEzB;;;;;;;GAOG;AACH,wBAAgB,UAAU,CACxB,KAAK,EAAE,KAAK,EACZ,YAAY,EAAE,YAAY,EAC1B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,GACzB,SAAS,EAAE,CAAC"}
package/dist/pick.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { EntityHandle, World } from '@forgeax/engine-ecs';
2
+ import { type Vec3Like } from '@forgeax/engine-math';
3
+ /**
4
+ * Result of a successful screen-to-entity pick.
5
+ *
6
+ * Minimal three-field surface (D-6):
7
+ * - `entity` — the picked `Entity` (packed u32 handle, ready for `world.get` / `world.set`)
8
+ * - `point` — the world-space ray/AABB entry point (`Vec3Like`; a 3-element array)
9
+ * - `distance` — the entry distance along the ray from the camera (>= 0)
10
+ *
11
+ * No `face` / `uv` / `normal` fields: AABB picking has no triangle resolution, so those
12
+ * would be a lie. A future mesh-precise pick spin-off owns them (requirements OOS).
13
+ */
14
+ export interface PickHit {
15
+ readonly entity: EntityHandle;
16
+ readonly point: Vec3Like;
17
+ readonly distance: number;
18
+ }
19
+ /**
20
+ * Raycast from a viewport-relative screen coordinate into the world and return the
21
+ * nearest pickable mesh entity whose world-space AABB the ray enters.
22
+ *
23
+ * @param world The ECS world holding the camera + candidate mesh entities (and the
24
+ * per-World SharedRefStore that owns each `MeshAsset` and its local-space `aabb`).
25
+ * @param cameraEntity The entity carrying the `Camera` component (and a Transform).
26
+ * @param screenX Horizontal pixel coordinate relative to the viewport top-left (y-down).
27
+ * @param screenY Vertical pixel coordinate.
28
+ * @param viewportWidth Viewport width in pixels.
29
+ * @param viewportHeight Viewport height in pixels.
30
+ * @returns The nearest `PickHit`, or `undefined` when the ray hits nothing.
31
+ * @throws {PickError} `code: 'camera-component-missing'` when `cameraEntity` has no `Camera`.
32
+ */
33
+ export declare function pick(world: World, cameraEntity: EntityHandle, screenX: number, screenY: number, viewportWidth: number, viewportHeight: number): PickHit | undefined;
34
+ //# sourceMappingURL=pick.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pick.d.ts","sourceRoot":"","sources":["../src/pick.ts"],"names":[],"mappings":"AAkCA,OAAO,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,EAAa,KAAK,QAAQ,EAAQ,MAAM,sBAAsB,CAAC;AAQtE;;;;;;;;;;GAUG;AACH,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,IAAI,CAClB,KAAK,EAAE,KAAK,EACZ,YAAY,EAAE,YAAY,EAC1B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,GACrB,OAAO,GAAG,SAAS,CAkErB"}
@@ -0,0 +1,12 @@
1
+ import type { EntityHandle, World } from '@forgeax/engine-ecs';
2
+ import type { ray } from '@forgeax/engine-math';
3
+ /**
4
+ * Unproject a viewport pixel into a world-space ray.
5
+ *
6
+ * `screenX`/`screenY` use the canvas convention: top-left origin, y down.
7
+ * The caller must run `propagateTransforms(world)` for the current frame first.
8
+ * A camera without a resolvable Transform returns `undefined`; a missing
9
+ * Camera component throws the package's structured `PickError`.
10
+ */
11
+ export declare function viewportToWorld(world: World, cameraEntity: EntityHandle, screenX: number, screenY: number, viewportWidth: number, viewportHeight: number): ray.Ray | undefined;
12
+ //# sourceMappingURL=viewport-to-world.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"viewport-to-world.d.ts","sourceRoot":"","sources":["../src/viewport-to-world.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,sBAAsB,CAAC;AAGhD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,KAAK,EACZ,YAAY,EAAE,YAAY,EAC1B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,GACrB,GAAG,CAAC,GAAG,GAAG,SAAS,CAGrB"}
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@forgeax/engine-picking",
3
+ "version": "0.1.2",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "sideEffects": false,
8
+ "description": "Screen-to-entity + vertex-level + tile-cell picking free functions for forgeax-engine (Tier 2.2 -- extracted from @forgeax/engine-runtime).",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "main": "./dist/index.mjs",
17
+ "types": "./dist/index.d.ts",
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "dependencies": {
25
+ "@forgeax/engine-ecs": "0.1.2",
26
+ "@forgeax/engine-math": "0.1.2",
27
+ "@forgeax/engine-render": "0.1.2",
28
+ "@forgeax/engine-runtime": "0.1.2",
29
+ "@forgeax/engine-scene": "0.1.2",
30
+ "@forgeax/engine-types": "0.1.2"
31
+ },
32
+ "devDependencies": {
33
+ "@forgeax/engine-geometry": "0.1.2",
34
+ "@forgeax/engine-pack": "0.1.2",
35
+ "@forgeax/engine-shader": "0.1.2"
36
+ },
37
+ "forgeax": {
38
+ "metrics": {
39
+ "bundle-size": {
40
+ "enabled": true,
41
+ "path": "dist/index.mjs",
42
+ "compression": "gzip"
43
+ },
44
+ "fps": {
45
+ "enabled": false,
46
+ "reason": "picking is a query library invoked on demand (click / hover), not a per-frame canvas; fps reported by hello-picking that consumes it"
47
+ },
48
+ "bench": {
49
+ "enabled": false,
50
+ "reason": "picking runs at interaction time (ray-AABB / ray-triangle per query), not per-frame; no CI micro-benchmark warranted"
51
+ },
52
+ "gate": {
53
+ "enabled": false,
54
+ "reason": "no package-level binary gate; smoke gate covered by hello-picking dawn smoke that exercises the pick contract"
55
+ },
56
+ "spike-report": {
57
+ "enabled": false,
58
+ "reason": "not a spike package; picking cluster extracted from runtime in feat-20260705-runtime-tier2-decomposition"
59
+ }
60
+ }
61
+ },
62
+ "scripts": {
63
+ "build": "tsup",
64
+ "test": "vitest run"
65
+ }
66
+ }
@@ -0,0 +1,131 @@
1
+ // glyph-text-pick.test.ts — pick() hits a baked GlyphText entity (AC-13).
2
+ //
3
+ // Extracted from packages/runtime/src/__tests__/text.unit.test.ts (the
4
+ // "from glyph-text-pick.test.ts" block) in feat-20260705 M2 / w25 when the pick
5
+ // cluster moved to @forgeax/engine-picking. Runtime can no longer import the
6
+ // picking package (AC-203), so this cross-cutting test (glyph text bake + pick)
7
+ // lives in the downstream picking package that depends on runtime.
8
+
9
+ import { type EntityHandle, World } from '@forgeax/engine-ecs';
10
+ import { CAMERA_PROJECTION_PERSPECTIVE, Camera } from '@forgeax/engine-render';
11
+ import {
12
+ GlyphText,
13
+ glyphTextLayoutSystem,
14
+ resetGlyphBakeCache,
15
+ } from '@forgeax/engine-render/authoring';
16
+ import { GpuResourceStore } from '@forgeax/engine-render/internal';
17
+ import { Transform } from '@forgeax/engine-scene';
18
+ import type { FontAsset, GlyphMetric, Handle } from '@forgeax/engine-types';
19
+ import { describe, expect, it } from 'vitest';
20
+ import { pick } from '../pick';
21
+
22
+ describe('glyph-text-pick', () => {
23
+ // Pick tests do not wire a GPU device; the residency store stays CPU-only.
24
+ const gpuStore = new GpuResourceStore();
25
+
26
+ const VP = 600;
27
+
28
+ function metric(): GlyphMetric {
29
+ return {
30
+ advance: 10,
31
+ bearingX: 0,
32
+ bearingY: 8,
33
+ size: { w: 8, h: 8 },
34
+ region: { x: 0, y: 0, w: 8, h: 8 },
35
+ };
36
+ }
37
+
38
+ function registerFont(world: World, chars: string): number {
39
+ const glyphs: Record<number, GlyphMetric> = {};
40
+ for (const ch of chars) glyphs[ch.codePointAt(0) as number] = metric();
41
+ const font: FontAsset = {
42
+ kind: 'font',
43
+ atlas: 0 as never,
44
+ sampler: 0 as never,
45
+ glyphs,
46
+ common: {
47
+ lineHeight: 12,
48
+ base: 8,
49
+ distanceRange: 4,
50
+ pxRange: 4,
51
+ atlasWidth: 64,
52
+ atlasHeight: 64,
53
+ },
54
+ };
55
+ return world.allocSharedRef('FontAsset', font) as unknown as number;
56
+ }
57
+
58
+ function makeWorld(): World {
59
+ const world = new World();
60
+ return world;
61
+ }
62
+
63
+ function spawnCamera(world: World, z: number): EntityHandle {
64
+ return world
65
+ .spawn(
66
+ {
67
+ component: Transform,
68
+ data: {
69
+ pos: [0, 0, z],
70
+ quat: [0, 0, 0, 1],
71
+ scale: [1, 1, 1],
72
+ },
73
+ },
74
+ {
75
+ component: Camera,
76
+ data: {
77
+ fov: Math.PI / 4,
78
+ aspect: 1,
79
+ near: 0.1,
80
+ far: 100,
81
+ projection: CAMERA_PROJECTION_PERSPECTIVE,
82
+ left: -1,
83
+ right: 1,
84
+ bottom: -1,
85
+ top: 1,
86
+ },
87
+ },
88
+ )
89
+ .unwrap();
90
+ }
91
+
92
+ function spawnLabel(world: World, fontId: number): EntityHandle {
93
+ return world
94
+ .spawn(
95
+ {
96
+ component: Transform,
97
+ data: {
98
+ pos: [0, 0, 0],
99
+ quat: [0, 0, 0, 1],
100
+ scale: [1, 1, 1],
101
+ },
102
+ },
103
+ {
104
+ component: GlyphText,
105
+ data: {
106
+ fontHandle: fontId as unknown as Handle<'FontAsset', 'shared'>,
107
+ text: 'Hi',
108
+ fontSize: 1,
109
+ color: [1, 1, 1, 1],
110
+ },
111
+ },
112
+ )
113
+ .unwrap();
114
+ }
115
+
116
+ describe('glyph text pick (AC-13, pick.ts unchanged)', () => {
117
+ it('(d) center-viewport ray hits the baked text entity', () => {
118
+ resetGlyphBakeCache();
119
+ const world = makeWorld();
120
+ const fontId = registerFont(world, 'Hi');
121
+ const camera = spawnCamera(world, 5);
122
+ const label = spawnLabel(world, fontId);
123
+
124
+ glyphTextLayoutSystem(world, gpuStore); // bake + attach MeshFilter + MeshRenderer
125
+
126
+ const hit = pick(world, camera, VP / 2, VP / 2, VP, VP);
127
+ expect(hit).toBeDefined();
128
+ expect(hit?.entity).toBe(label);
129
+ });
130
+ });
131
+ });
@@ -0,0 +1,66 @@
1
+ // pick-errors.test-d.ts — PickError code-owner and closed-union declaration guard.
2
+ //
3
+ // The guard proves the package barrel keeps the public PickErrorCode projection
4
+ // equal to the PickError.code owner, rejects an invalid literal, and remains
5
+ // exhaustively switchable without a `default` arm. Adding a member later without
6
+ // extending the switch breaks the declaration typecheck.
7
+ //
8
+ // Both closed unions the picking barrel exports are covered:
9
+ // - PickErrorCode — single member ('camera-component-missing'), thrown by pick().
10
+ // - PickTileError — two-member discriminated union (code), returned by pickTile().
11
+ //
12
+ // Charter P3 (tension): closed unions must retain zero-loss exhaustive-switch
13
+ // capability across the package boundary — this file is the compile-time proof.
14
+
15
+ // (b) external-consumer import path: the type resolves from the package barrel.
16
+ import type { PickError, PickErrorCode, PickTileError } from '@forgeax/engine-picking';
17
+ import { expectTypeOf, test } from 'vitest';
18
+
19
+ function assertNever(_x: never): never {
20
+ throw new Error('exhaustive');
21
+ }
22
+
23
+ // PickErrorCode exhaustive switch, no default — assertNever traps a future
24
+ // member that is added without extending this switch.
25
+ function describePickErrorCode(code: PickErrorCode): string {
26
+ switch (code) {
27
+ case 'camera-component-missing':
28
+ return 'camera missing';
29
+ default:
30
+ return assertNever(code);
31
+ }
32
+ }
33
+
34
+ // (c) PickTileError exhaustive switch over the discriminant, no default.
35
+ function describePickTileError(err: PickTileError): string {
36
+ switch (err.code) {
37
+ case 'tilemap-not-found':
38
+ return 'tilemap not found';
39
+ case 'tilemap-component-missing':
40
+ return 'tilemap component missing';
41
+ default:
42
+ return assertNever(err);
43
+ }
44
+ }
45
+
46
+ test('PickErrorCode derives the exact closed surface from PickError', () => {
47
+ expectTypeOf<PickErrorCode>().toEqualTypeOf<PickError['code']>();
48
+ expectTypeOf<PickErrorCode>().toEqualTypeOf<'camera-component-missing'>();
49
+
50
+ const acceptPickErrorCode = (code: PickErrorCode): void => {
51
+ void code;
52
+ };
53
+ acceptPickErrorCode('camera-component-missing');
54
+ // @ts-expect-error invalid codes must not be accepted.
55
+ acceptPickErrorCode('not-a-pick-error');
56
+
57
+ // Compile-time exhaustiveness (run-time noop).
58
+ void describePickErrorCode;
59
+ });
60
+
61
+ test('PickTileError is the closed two-member discriminated union', () => {
62
+ expectTypeOf<PickTileError['code']>().toEqualTypeOf<
63
+ 'tilemap-not-found' | 'tilemap-component-missing'
64
+ >();
65
+ void describePickTileError;
66
+ });
@@ -0,0 +1,71 @@
1
+ // pick-errors.test.ts — PickErrorCode closed union + PickError structured surface.
2
+ //
3
+ // Extracted from packages/runtime/src/__tests__/errors.unit.test.ts (the
4
+ // "from pick-errors.test.ts" block) in feat-20260705 M2 / w25 when the pick
5
+ // cluster moved to @forgeax/engine-picking. Runtime can no longer import the
6
+ // picking package (AC-203: no runtime -> picking edge), so these unit tests
7
+ // live alongside the code they exercise.
8
+
9
+ import { describe, expect, it } from 'vitest';
10
+ import { PickError, type PickErrorCode } from '../pick-errors';
11
+
12
+ describe('pick-errors', () => {
13
+ const KEBAB_REGEX = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$/;
14
+
15
+ describe('w8 — PickErrorCode closed union (AC-13)', () => {
16
+ it('camera-component-missing is a valid PickErrorCode literal', () => {
17
+ const code: PickErrorCode = 'camera-component-missing';
18
+ expect(code).toBe('camera-component-missing');
19
+ });
20
+
21
+ it('camera-component-missing is valid kebab-case', () => {
22
+ const code: PickErrorCode = 'camera-component-missing';
23
+ expect(code).toMatch(KEBAB_REGEX);
24
+ });
25
+
26
+ it('exhaustive switch over PickErrorCode compiles without default', () => {
27
+ function exhaustive(code: PickErrorCode): string {
28
+ switch (code) {
29
+ case 'camera-component-missing':
30
+ return 'camera missing';
31
+ }
32
+ }
33
+ expect(exhaustive('camera-component-missing')).toBe('camera missing');
34
+ });
35
+ });
36
+
37
+ describe('w8 — PickError structured 3-field surface (AC-11)', () => {
38
+ it('PickError has .code === camera-component-missing', () => {
39
+ const e = new PickError(7);
40
+ expect(e.code).toBe('camera-component-missing');
41
+ });
42
+
43
+ it('PickError .expected is a non-empty string', () => {
44
+ const e = new PickError(7);
45
+ expect(typeof e.expected).toBe('string');
46
+ expect(e.expected.length).toBeGreaterThan(0);
47
+ });
48
+
49
+ it('PickError .hint contains a world.set recovery directive', () => {
50
+ const e = new PickError(7);
51
+ expect(e.hint.length).toBeGreaterThan(0);
52
+ expect(e.hint).toContain('world.set');
53
+ });
54
+
55
+ it('PickError super message (Error.message) is non-empty', () => {
56
+ const e = new PickError(7);
57
+ expect(e.message.length).toBeGreaterThan(0);
58
+ });
59
+
60
+ it('PickError is an instanceof Error and carries .name', () => {
61
+ const e = new PickError(7);
62
+ expect(e).toBeInstanceOf(Error);
63
+ expect(e.name).toBe('PickError');
64
+ });
65
+
66
+ it('PickError .detail records the offending camera entity', () => {
67
+ const e = new PickError(42);
68
+ expect(e.detail).toEqual({ cameraEntity: 42 });
69
+ });
70
+ });
71
+ });