@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.
- package/LICENSE +202 -0
- package/README.md +168 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/glyph-text-pick.test.d.ts +2 -0
- package/dist/__tests__/glyph-text-pick.test.d.ts.map +1 -0
- package/dist/__tests__/pick-errors.test-d.d.ts +2 -0
- package/dist/__tests__/pick-errors.test-d.d.ts.map +1 -0
- package/dist/__tests__/pick-errors.test.d.ts +2 -0
- package/dist/__tests__/pick-errors.test.d.ts.map +1 -0
- package/dist/__tests__/pick-tile.test.d.ts +2 -0
- package/dist/__tests__/pick-tile.test.d.ts.map +1 -0
- package/dist/__tests__/pick-vertex.unit.test.d.ts +2 -0
- package/dist/__tests__/pick-vertex.unit.test.d.ts.map +1 -0
- package/dist/__tests__/pick.test.d.ts +2 -0
- package/dist/__tests__/pick.test.d.ts.map +1 -0
- package/dist/__tests__/visibility-regression.integration.test.d.ts +2 -0
- package/dist/__tests__/visibility-regression.integration.test.d.ts.map +1 -0
- package/dist/asset-port.d.ts +6 -0
- package/dist/asset-port.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +444 -0
- package/dist/index.mjs.map +1 -0
- package/dist/pick-core.d.ts +39 -0
- package/dist/pick-core.d.ts.map +1 -0
- package/dist/pick-errors.d.ts +39 -0
- package/dist/pick-errors.d.ts.map +1 -0
- package/dist/pick-tile.d.ts +44 -0
- package/dist/pick-tile.d.ts.map +1 -0
- package/dist/pick-vertex.d.ts +87 -0
- package/dist/pick-vertex.d.ts.map +1 -0
- package/dist/pick.d.ts +34 -0
- package/dist/pick.d.ts.map +1 -0
- package/dist/viewport-to-world.d.ts +12 -0
- package/dist/viewport-to-world.d.ts.map +1 -0
- package/package.json +66 -0
- package/src/__tests__/glyph-text-pick.test.ts +131 -0
- package/src/__tests__/pick-errors.test-d.ts +66 -0
- package/src/__tests__/pick-errors.test.ts +71 -0
- package/src/__tests__/pick-tile.test.ts +239 -0
- package/src/__tests__/pick-vertex.unit.test.ts +1588 -0
- package/src/__tests__/pick.test.ts +525 -0
- package/src/__tests__/visibility-regression.integration.test.ts +74 -0
- package/src/asset-port.ts +13 -0
- package/src/index.ts +21 -0
- package/src/pick-core.ts +114 -0
- package/src/pick-errors.ts +69 -0
- package/src/pick-tile.ts +157 -0
- package/src/pick-vertex.ts +593 -0
- package/src/pick.ts +148 -0
- 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
|
+
});
|