@forgeax/engine-picking 0.0.0-dev.8d955ade1c79

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__/helpers/mock-shader-registry.d.ts +3 -0
  7. package/dist/__tests__/helpers/mock-shader-registry.d.ts.map +1 -0
  8. package/dist/__tests__/pick-errors.test-d.d.ts +2 -0
  9. package/dist/__tests__/pick-errors.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/pick-errors.test.d.ts +2 -0
  11. package/dist/__tests__/pick-errors.test.d.ts.map +1 -0
  12. package/dist/__tests__/pick-tile.test.d.ts +2 -0
  13. package/dist/__tests__/pick-tile.test.d.ts.map +1 -0
  14. package/dist/__tests__/pick-vertex.unit.test.d.ts +2 -0
  15. package/dist/__tests__/pick-vertex.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/pick.test.d.ts +2 -0
  17. package/dist/__tests__/pick.test.d.ts.map +1 -0
  18. package/dist/__tests__/visibility-regression.integration.test.d.ts +2 -0
  19. package/dist/__tests__/visibility-regression.integration.test.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 +440 -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 +132 -0
  38. package/src/__tests__/helpers/mock-shader-registry.ts +118 -0
  39. package/src/__tests__/pick-errors.test-d.ts +66 -0
  40. package/src/__tests__/pick-errors.test.ts +71 -0
  41. package/src/__tests__/pick-tile.test.ts +239 -0
  42. package/src/__tests__/pick-vertex.unit.test.ts +1607 -0
  43. package/src/__tests__/pick.test.ts +538 -0
  44. package/src/__tests__/visibility-regression.integration.test.ts +73 -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
+ import type { EntityHandle, World } from '@forgeax/engine-ecs';
2
+ import { mat4, ray } from '@forgeax/engine-math';
3
+ import { type CameraProjection } from '@forgeax/engine-render';
4
+ /**
5
+ * Read an entity's resolved world mat4 (16 column-major floats) from the
6
+ * `Transform.world` column array view (feat-20260601 D-3). Returns a fresh
7
+ * copy (the view aliases live slot bytes); `undefined` when the entity has no
8
+ * Transform / world column.
9
+ */
10
+ export declare function readWorldMatrix(world: World, entity: EntityHandle): Float32Array | undefined;
11
+ /**
12
+ * The screen-to-world ray plus the camera matrices used to build it.
13
+ *
14
+ * - `ray` — the unprojected world-space pick ray (origin + direction).
15
+ * - `view` — `invert(camera Transform.world)`.
16
+ * - `proj` — the camera projection matrix (perspective / orthographic).
17
+ * - `projectionKind` — the resolved camera projection discriminant.
18
+ *
19
+ * `view` and `proj` are returned separately so vertex picking can build its own
20
+ * `viewProj = proj * view` for `worldToScreen` without recomputing the branch.
21
+ */
22
+ export interface ScreenRay {
23
+ readonly ray: ray.Ray;
24
+ readonly view: mat4.Mat4;
25
+ readonly proj: mat4.Mat4;
26
+ readonly projectionKind: CameraProjection;
27
+ }
28
+ /**
29
+ * Build the screen-to-world ray for `cameraEntity` at the viewport-relative
30
+ * `(screenX, screenY)` coordinate: validate the camera component, read its world
31
+ * transform, invert it to a view matrix, branch the projection on the camera
32
+ * discriminant, and unproject the coordinate into a world-space ray.
33
+ *
34
+ * @returns The `ScreenRay`, or `undefined` when the camera entity carries no
35
+ * resolvable `Transform.world` (a degenerate miss — no view matrix can be built).
36
+ * @throws {PickError} `code: 'camera-component-missing'` when `cameraEntity` has no `Camera`.
37
+ */
38
+ export declare function computeScreenRay(world: World, cameraEntity: EntityHandle, screenX: number, screenY: number, viewportWidth: number, viewportHeight: number): ScreenRay | undefined;
39
+ //# sourceMappingURL=pick-core.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pick-core.d.ts","sourceRoot":"","sources":["../src/pick-core.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,sBAAsB,CAAC;AACjD,OAAO,EAAU,KAAK,gBAAgB,EAA2B,MAAM,wBAAwB,CAAC;AAIhG;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,GAAG,YAAY,GAAG,SAAS,CAG5F;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC,GAAG,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC;IACzB,QAAQ,CAAC,cAAc,EAAE,gBAAgB,CAAC;CAC3C;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC9B,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,CA6CvB"}
@@ -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":"AA8CA,OAAO,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,EAAa,KAAK,QAAQ,EAAc,MAAM,sBAAsB,CAAC;AAS5E;;;;;;;;;;;;;;;;;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":"AAmCA,OAAO,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,EAAa,KAAK,QAAQ,EAAQ,MAAM,sBAAsB,CAAC;AAOtE;;;;;;;;;;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.0.0-dev.8d955ade1c79",
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-assets-runtime": "0.0.0-dev.8d955ade1c79",
26
+ "@forgeax/engine-ecs": "0.0.0-dev.8d955ade1c79",
27
+ "@forgeax/engine-math": "0.0.0-dev.8d955ade1c79",
28
+ "@forgeax/engine-render": "0.0.0-dev.8d955ade1c79",
29
+ "@forgeax/engine-runtime": "0.0.0-dev.8d955ade1c79",
30
+ "@forgeax/engine-scene": "0.0.0-dev.8d955ade1c79",
31
+ "@forgeax/engine-types": "0.0.0-dev.8d955ade1c79"
32
+ },
33
+ "devDependencies": {
34
+ "@forgeax/engine-pack": "0.0.0-dev.8d955ade1c79",
35
+ "@forgeax/engine-shader": "0.0.0-dev.8d955ade1c79"
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,132 @@
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 { GlyphText } from '@forgeax/engine-render/authoring';
12
+ import { propagateTransforms, Transform } from '@forgeax/engine-scene';
13
+ import type { FontAsset, GlyphMetric, Handle } from '@forgeax/engine-types';
14
+ import { describe, expect, it } from 'vitest';
15
+ import { GpuResidencyCache } from '../../../render/src/device/gpu-residency';
16
+ import {
17
+ glyphTextLayoutSystem,
18
+ resetGlyphBakeCache,
19
+ } from '../../../render/src/glyph-text-layout-system';
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 GpuResidencyCache();
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
+ propagateTransforms(world);
126
+
127
+ const hit = pick(world, camera, VP / 2, VP / 2, VP, VP);
128
+ expect(hit).toBeDefined();
129
+ expect(hit?.entity).toBe(label);
130
+ });
131
+ });
132
+ });
@@ -0,0 +1,118 @@
1
+ // Shared test helper: minimal mock ShaderRegistry for AssetRegistry constructor.
2
+ // feat-20260527-material-registration-unification M1 / w4.
3
+ //
4
+ // All tests that create AssetRegistry directly must pass a ShaderRegistry
5
+ // since the constructor parameter became required in w1. This helper
6
+ // provides a zero-config mock that satisfies the constructor signature
7
+ // without requiring a real GPU device.
8
+
9
+ import { ShaderRegistry, type ShaderRegistryDevice } from '@forgeax/engine-shader';
10
+
11
+ export function makeMockShaderRegistry(): ShaderRegistry {
12
+ const mockDevice: ShaderRegistryDevice = {
13
+ createShaderModule() {
14
+ return {
15
+ ok: true,
16
+ value: undefined,
17
+ unwrap: () => undefined,
18
+ unwrapOr: (d: unknown) => d,
19
+ } as unknown as ReturnType<ShaderRegistryDevice['createShaderModule']>;
20
+ },
21
+ };
22
+ const sr = new ShaderRegistry({
23
+ device: mockDevice,
24
+ manifestUrl: undefined,
25
+ });
26
+ // Register a default test shader so M2 tests can validate against it.
27
+ sr.installMaterialArtifact('test::standard', {
28
+ source: 'fn main() {}',
29
+ paramSchema: [
30
+ { name: 'baseColor', type: 'color' },
31
+ { name: 'metallic', type: 'f32' },
32
+ { name: 'roughness', type: 'f32' },
33
+ ],
34
+ });
35
+ sr.installMaterialArtifact('test::unlit', {
36
+ source: 'fn main() {}',
37
+ paramSchema: [
38
+ { name: 'baseColor', type: 'color' },
39
+ { name: 'baseColorTexture', type: 'texture2d' },
40
+ { name: 'sampler', type: 'sampler', default: null },
41
+ { name: 'lightIntensity', type: 'f32' }, // required — no default
42
+ ],
43
+ });
44
+ // Register the engine-shipped default-standard-pbr shader so tests
45
+ // that use it pass the register-time validation gate (M2 / w6).
46
+ // paramSchema mirrors packages/shader/src/default-standard-pbr.schema.json.
47
+ sr.installMaterialArtifact('forgeax::default-standard-pbr', {
48
+ source: 'fn main() {}',
49
+ paramSchema: [
50
+ { name: 'baseColor', type: 'color', default: [1.0, 1.0, 1.0, 1.0] },
51
+ { name: 'metallic', type: 'f32', default: 0.0 },
52
+ { name: 'roughness', type: 'f32', default: 0.5 },
53
+ // feat-20260613 fix-issue-1 (D-8): channelMap split into 4 f32 selectors.
54
+ { name: 'metallicChannel', type: 'f32', default: 2.0 },
55
+ { name: 'roughnessChannel', type: 'f32', default: 1.0 },
56
+ { name: 'aoChannel', type: 'f32', default: 0.0 },
57
+ { name: 'extraChannel', type: 'f32', default: 0.0 },
58
+ { name: 'emissive', type: 'vec3', default: [0.0, 0.0, 0.0] },
59
+ { name: 'emissiveIntensity', type: 'f32', default: 0.0 },
60
+ { name: 'occlusionStrength', type: 'f32', default: 1.0 },
61
+ { name: 'baseColorTexture', type: 'texture2d' },
62
+ { name: 'metallicRoughnessTexture', type: 'texture2d' },
63
+ { name: 'normalTexture', type: 'texture2d' },
64
+ ],
65
+ });
66
+ sr.installMaterialArtifact('forgeax::default-unlit', {
67
+ source: 'fn main() {}',
68
+ paramSchema: [
69
+ { name: 'baseColor', type: 'color' },
70
+ { name: 'baseColorTexture', type: 'texture2d' },
71
+ { name: 'sampler', type: 'sampler', default: null },
72
+ ],
73
+ });
74
+ // test::dummy used by M1 test (c)
75
+ sr.installMaterialArtifact('test::dummy', {
76
+ source: 'fn main() {}',
77
+ paramSchema: [],
78
+ });
79
+ // forgeax::msdf-text used by the glyph layout system (feat-20260531 F-1).
80
+ // paramSchema mirrors packages/shader/src/msdf-text.material.json so the
81
+ // layout system's per-font MaterialAsset register passes validation in the
82
+ // GPU-less unit tests.
83
+ sr.installMaterialArtifact('forgeax::msdf-text', {
84
+ source: 'fn main() {}',
85
+ paramSchema: [
86
+ { name: 'tintColor', type: 'color', default: [1.0, 1.0, 1.0, 1.0] },
87
+ { name: 'distanceRange', type: 'f32', default: 4.0 },
88
+ { name: 'baseColorTexture', type: 'texture2d' },
89
+ { name: 'sampler', type: 'sampler', default: null },
90
+ ],
91
+ });
92
+ // feat-20260609-pipeline-driven-pass-selector-shadowcaster-via-mat M3 / T-007:
93
+ // forgeax::default-shadow-caster — vertex-only depth pass shader for
94
+ // directional shadow maps. Registered as the 6th built-in material shader.
95
+ sr.installMaterialArtifact('forgeax::default-shadow-caster', {
96
+ source: 'fn main() {}',
97
+ paramSchema: [],
98
+ });
99
+ // feat-20260608-tilemap-object-layer-rendering M0 baseline rebuild:
100
+ // forgeax::sprite registered in the mock so resolveTilesetMaterial inside
101
+ // tilemap-chunk-extract-system can register a per-tile material in unit
102
+ // tests (paramSchema mirrors packages/shader/src/sprite.material.json).
103
+ sr.installMaterialArtifact('forgeax::sprite', {
104
+ source: 'fn main() {}',
105
+ paramSchema: [
106
+ { name: 'baseColor', type: 'color', default: [1.0, 1.0, 1.0, 1.0] },
107
+ { name: 'texture', type: 'texture2d' },
108
+ { name: 'sampler', type: 'sampler', default: null },
109
+ { name: 'region', type: 'vec4', default: [0.0, 0.0, 1.0, 1.0] },
110
+ { name: 'pivot', type: 'vec2', default: [0.5, 0.5] },
111
+ { name: 'flipX', type: 'f32', default: 0.0 },
112
+ { name: 'flipY', type: 'f32', default: 0.0 },
113
+ { name: 'slices', type: 'vec4', default: [0.0, 0.0, 0.0, 0.0] },
114
+ { name: 'sliceMode', type: 'f32', default: 0.0 },
115
+ ],
116
+ });
117
+ return sr;
118
+ }