@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,239 @@
1
+ // pick-tile.test - pickTile cell-level query (worldX/Y -> tileId)
2
+ // (feat-20260608 M0 baseline rebuild).
3
+ //
4
+ // Anchors: plan-tasks m0-t7 / m0-t8; plan-strategy §D-5 / §M0 targetFiles
5
+ // (pickTile); feat-20260604 baseline; charter P3 explicit failure.
6
+
7
+ import { World } from '@forgeax/engine-ecs';
8
+ import { TileLayer, Tilemap } from '@forgeax/engine-render/authoring';
9
+ import { ChildOf, propagateTransforms, Transform } from '@forgeax/engine-scene';
10
+ import { describe, expect, it } from 'vitest';
11
+ import { pickTile } from '../pick-tile';
12
+
13
+ function makeFixture(opts: {
14
+ cols: number;
15
+ rows: number;
16
+ layers: ReadonlyArray<{ layerOrder: number; tiles: readonly number[] }>;
17
+ }) {
18
+ const world = new World();
19
+ const tilemap = world
20
+ .spawn(
21
+ { component: Tilemap, data: { cols: opts.cols, rows: opts.rows, tileset: 'test/tileset' } },
22
+ { component: Transform, data: {} },
23
+ )
24
+ .unwrap();
25
+ const layerEntities = opts.layers.map((spec) => {
26
+ const tilesArr = new Uint32Array(opts.cols * opts.rows);
27
+ for (let i = 0; i < spec.tiles.length; i++) tilesArr[i] = spec.tiles[i] ?? 0;
28
+ return world
29
+ .spawn(
30
+ { component: TileLayer, data: { tiles: tilesArr, layerOrder: spec.layerOrder } },
31
+ { component: ChildOf, data: { parent: tilemap } },
32
+ )
33
+ .unwrap();
34
+ });
35
+ return { world, tilemap, layerEntities };
36
+ }
37
+
38
+ describe('pickTile (M0 baseline)', () => {
39
+ it('hits the cell containing worldX/Y on a single-layer tilemap', () => {
40
+ const { world, tilemap } = makeFixture({
41
+ cols: 4,
42
+ rows: 4,
43
+ layers: [{ layerOrder: 0, tiles: [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }],
44
+ });
45
+ const r = pickTile(world, tilemap, 1.5, 1.5);
46
+ expect(r.ok).toBe(true);
47
+ if (r.ok) {
48
+ expect(r.value).not.toBeNull();
49
+ if (r.value !== null) {
50
+ expect(r.value.cellX).toBe(1);
51
+ expect(r.value.cellY).toBe(1);
52
+ expect(r.value.tileId).toBe(1);
53
+ }
54
+ }
55
+ });
56
+
57
+ it('returns Result.ok(null) when worldX/Y is out of bounds', () => {
58
+ const { world, tilemap } = makeFixture({
59
+ cols: 2,
60
+ rows: 2,
61
+ layers: [{ layerOrder: 0, tiles: [1, 0, 0, 0] }],
62
+ });
63
+ const r = pickTile(world, tilemap, 100, 100);
64
+ expect(r.ok).toBe(true);
65
+ if (r.ok) expect(r.value).toBeNull();
66
+ });
67
+
68
+ it('returns Result.ok(null) when no non-zero tile occupies the cell', () => {
69
+ const { world, tilemap } = makeFixture({
70
+ cols: 2,
71
+ rows: 2,
72
+ layers: [{ layerOrder: 0, tiles: [1, 0, 0, 0] }],
73
+ });
74
+ const r = pickTile(world, tilemap, 1.5, 1.5);
75
+ expect(r.ok).toBe(true);
76
+ if (r.ok) expect(r.value).toBeNull();
77
+ });
78
+
79
+ it('walks layers in layerOrder-descending order (high layer wins)', () => {
80
+ // layer 0: cell (0,0) has tile 1
81
+ // layer 5: cell (0,0) has tile 7
82
+ const { world, tilemap } = makeFixture({
83
+ cols: 2,
84
+ rows: 2,
85
+ layers: [
86
+ { layerOrder: 0, tiles: [1, 0, 0, 0] },
87
+ { layerOrder: 5, tiles: [7, 0, 0, 0] },
88
+ ],
89
+ });
90
+ const r = pickTile(world, tilemap, 0.5, 0.5);
91
+ expect(r.ok).toBe(true);
92
+ if (r.ok && r.value !== null) {
93
+ expect(r.value.tileId).toBe(7);
94
+ } else {
95
+ expect.fail('expected a hit');
96
+ }
97
+ });
98
+
99
+ it('uses the propagated inverse affine transform for translated rotated scaled maps', () => {
100
+ const world = new World();
101
+ const tilemap = world
102
+ .spawn(
103
+ {
104
+ component: Tilemap,
105
+ data: { cols: 3, rows: 2, tileSize: [1, 1], tileset: 'test/tileset' },
106
+ },
107
+ {
108
+ component: Transform,
109
+ data: {
110
+ pos: [10, 20, 0],
111
+ // +90 degrees around Z: local cell (1.5, 0.5) maps to world (8.5, 23).
112
+ quat: [0, 0, Math.SQRT1_2, Math.SQRT1_2],
113
+ scale: [2, 3, 1],
114
+ },
115
+ },
116
+ )
117
+ .unwrap();
118
+ const lowerLayer = world
119
+ .spawn(
120
+ {
121
+ component: TileLayer,
122
+ data: { tiles: new Uint32Array([0, 3, 0, 0, 0, 0]), layerOrder: 0 },
123
+ },
124
+ { component: ChildOf, data: { parent: tilemap } },
125
+ )
126
+ .unwrap();
127
+ const upperLayer = world
128
+ .spawn(
129
+ {
130
+ component: TileLayer,
131
+ data: { tiles: new Uint32Array([0, 9, 0, 0, 0, 0]), layerOrder: 7 },
132
+ },
133
+ { component: ChildOf, data: { parent: tilemap } },
134
+ )
135
+ .unwrap();
136
+
137
+ propagateTransforms(world).unwrap();
138
+ const r = pickTile(world, tilemap, 8.5, 23);
139
+
140
+ expect(r.ok).toBe(true);
141
+ if (r.ok) {
142
+ expect(r.value).toEqual({ layerEntity: upperLayer, cellX: 1, cellY: 0, tileId: 9 });
143
+ }
144
+ expect(lowerLayer).not.toBe(upperLayer);
145
+ });
146
+
147
+ it('falls through to lower layer when higher layer has 0 at the cell', () => {
148
+ const { world, tilemap } = makeFixture({
149
+ cols: 2,
150
+ rows: 2,
151
+ layers: [
152
+ { layerOrder: 0, tiles: [3, 0, 0, 0] },
153
+ { layerOrder: 5, tiles: [0, 0, 0, 0] },
154
+ ],
155
+ });
156
+ const r = pickTile(world, tilemap, 0.5, 0.5);
157
+ expect(r.ok).toBe(true);
158
+ if (r.ok && r.value !== null) {
159
+ expect(r.value.tileId).toBe(3);
160
+ } else {
161
+ expect.fail('expected a hit');
162
+ }
163
+ });
164
+
165
+ it('returns tilemap-not-found for a dead handle', () => {
166
+ const { world } = makeFixture({
167
+ cols: 1,
168
+ rows: 1,
169
+ layers: [{ layerOrder: 0, tiles: [1] }],
170
+ });
171
+ const dead = world.spawn({ component: Transform, data: {} }).unwrap();
172
+ world.despawn(dead).unwrap();
173
+ const r = pickTile(world, dead, 1, 1);
174
+ expect(r.ok).toBe(false);
175
+ if (!r.ok) expect(r.error.code).toBe('tilemap-not-found');
176
+ });
177
+
178
+ it('returns Result.err when the entity does not carry Tilemap', () => {
179
+ const { world } = makeFixture({
180
+ cols: 1,
181
+ rows: 1,
182
+ layers: [{ layerOrder: 0, tiles: [1] }],
183
+ });
184
+ const e = world.spawn({ component: Transform, data: {} }).unwrap();
185
+ const r = pickTile(world, e, 1, 1);
186
+ expect(r.ok).toBe(false);
187
+ if (!r.ok) {
188
+ expect(r.error.code).toBe('tilemap-component-missing');
189
+ }
190
+ });
191
+
192
+ it('repairs a missing Tilemap on the same World and retries successfully', () => {
193
+ const world = new World();
194
+ const entity = world.spawn({ component: Transform, data: {} }).unwrap();
195
+
196
+ const missing = pickTile(world, entity, 0.5, 0.5);
197
+ expect(missing.ok).toBe(false);
198
+ if (!missing.ok) expect(missing.error.code).toBe('tilemap-component-missing');
199
+
200
+ world
201
+ .addComponent(entity, {
202
+ component: Tilemap,
203
+ data: { cols: 1, rows: 1, tileSize: [1, 1], tileset: 'test/tileset' },
204
+ })
205
+ .unwrap();
206
+ const layer = world
207
+ .spawn(
208
+ { component: TileLayer, data: { tiles: new Uint32Array([11]), layerOrder: 0 } },
209
+ { component: ChildOf, data: { parent: entity } },
210
+ )
211
+ .unwrap();
212
+
213
+ const repaired = pickTile(world, entity, 0.5, 0.5);
214
+ expect(repaired.ok).toBe(true);
215
+ if (repaired.ok) {
216
+ expect(repaired.value).toEqual({ layerEntity: layer, cellX: 0, cellY: 0, tileId: 11 });
217
+ }
218
+ });
219
+
220
+ it('keeps the documented mat4 singular fallback deterministic', () => {
221
+ const { world, tilemap } = makeFixture({
222
+ cols: 1,
223
+ rows: 1,
224
+ layers: [{ layerOrder: 0, tiles: [5] }],
225
+ });
226
+ world.set(tilemap, Transform, { pos: [7, 9, 0], scale: [0, 2, 1] }).unwrap();
227
+ propagateTransforms(world).unwrap();
228
+
229
+ // mat4.invert defines singular input as identity; pickTile keeps that
230
+ // central math fallback instead of inventing another error arm.
231
+ const first = pickTile(world, tilemap, 0.5, 0.5);
232
+ const second = pickTile(world, tilemap, 0.5, 0.5);
233
+ expect(first).toEqual(second);
234
+ expect(first.ok).toBe(true);
235
+ if (first.ok) {
236
+ expect(first.value).toMatchObject({ cellX: 0, cellY: 0, tileId: 5 });
237
+ }
238
+ });
239
+ });