@forgeax/engine-picking 0.1.3 → 0.1.6
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/README.md +2 -2
- package/dist/.tsbuildinfo +1 -1
- package/dist/__tests__/helpers/mock-shader-registry.d.ts +3 -0
- package/dist/__tests__/helpers/mock-shader-registry.d.ts.map +1 -0
- package/dist/index.mjs +4 -8
- package/dist/index.mjs.map +1 -1
- package/dist/pick-vertex.d.ts.map +1 -1
- package/dist/pick.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/__tests__/glyph-text-pick.test.ts +8 -7
- package/src/__tests__/helpers/mock-shader-registry.ts +118 -0
- package/src/__tests__/pick-vertex.unit.test.ts +80 -61
- package/src/__tests__/pick.test.ts +26 -13
- package/src/__tests__/visibility-regression.integration.test.ts +3 -4
- package/src/pick-vertex.ts +4 -4
- package/src/pick.ts +3 -3
- package/dist/asset-port.d.ts +0 -6
- package/dist/asset-port.d.ts.map +0 -1
- package/src/asset-port.ts +0 -13
|
@@ -13,24 +13,25 @@
|
|
|
13
13
|
// TDD red phase (w7): pickVertex full-scene tests covering AC-02 (three-state
|
|
14
14
|
// return) + R-2 (AABB coarse cull multi-entity) + limit > available vertices.
|
|
15
15
|
|
|
16
|
+
import { AssetRegistry, resolveAssetHandle } from '@forgeax/engine-assets-runtime';
|
|
16
17
|
import { type EntityHandle, World } from '@forgeax/engine-ecs';
|
|
17
|
-
import { createPrimitiveMesh } from '@forgeax/engine-geometry';
|
|
18
18
|
import { mat4, ray, type Vec3Like, vec2, vec3 } from '@forgeax/engine-math';
|
|
19
|
+
import { AssetGuid } from '@forgeax/engine-pack/guid';
|
|
19
20
|
import {
|
|
20
21
|
CAMERA_PROJECTION_PERSPECTIVE,
|
|
21
22
|
Camera,
|
|
22
23
|
cameraProjectionFromF32,
|
|
23
|
-
Materials,
|
|
24
24
|
MeshFilter,
|
|
25
25
|
MeshRenderer,
|
|
26
26
|
} from '@forgeax/engine-render';
|
|
27
27
|
import { propagateTransforms, Transform } from '@forgeax/engine-scene';
|
|
28
28
|
|
|
29
|
-
import type { Handle, MeshAsset, VertexAttributeMap } from '@forgeax/engine-types';
|
|
29
|
+
import type { Handle, MaterialAsset, MeshAsset, VertexAttributeMap } from '@forgeax/engine-types';
|
|
30
|
+
import { toShared } from '@forgeax/engine-types';
|
|
30
31
|
import { describe, expect, it } from 'vitest';
|
|
31
|
-
import { resolvePickingAsset } from '../asset-port';
|
|
32
32
|
import { PickError } from '../pick-errors';
|
|
33
33
|
import { pickVertex, pickVertexOnEntity, type VertexHit } from '../pick-vertex';
|
|
34
|
+
import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
|
|
34
35
|
|
|
35
36
|
// ── shared constants ─────────────────────────────────────────────────────
|
|
36
37
|
|
|
@@ -52,42 +53,29 @@ function translateTransform(x: number, y: number, z: number) {
|
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
function aabbFromPositions(positions: ArrayLike<number>): Float32Array {
|
|
56
|
-
let minX = Number.POSITIVE_INFINITY;
|
|
57
|
-
let minY = Number.POSITIVE_INFINITY;
|
|
58
|
-
let minZ = Number.POSITIVE_INFINITY;
|
|
59
|
-
let maxX = Number.NEGATIVE_INFINITY;
|
|
60
|
-
let maxY = Number.NEGATIVE_INFINITY;
|
|
61
|
-
let maxZ = Number.NEGATIVE_INFINITY;
|
|
62
|
-
for (let i = 0; i < positions.length; i += 3) {
|
|
63
|
-
minX = Math.min(minX, positions[i] as number);
|
|
64
|
-
minY = Math.min(minY, positions[i + 1] as number);
|
|
65
|
-
minZ = Math.min(minZ, positions[i + 2] as number);
|
|
66
|
-
maxX = Math.max(maxX, positions[i] as number);
|
|
67
|
-
maxY = Math.max(maxY, positions[i + 1] as number);
|
|
68
|
-
maxZ = Math.max(maxZ, positions[i + 2] as number);
|
|
69
|
-
}
|
|
70
|
-
return new Float32Array([minX, minY, minZ, maxX, maxY, maxZ]);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
56
|
interface Scene {
|
|
74
57
|
world: World;
|
|
58
|
+
assets: AssetRegistry;
|
|
75
59
|
material: Handle<'MaterialAsset', 'shared'>;
|
|
76
60
|
}
|
|
77
61
|
|
|
78
62
|
function makeScene(): Scene {
|
|
79
63
|
const world = new World();
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
64
|
+
const assets = new AssetRegistry(makeMockShaderRegistry());
|
|
65
|
+
const matResult = assets.catalog<MaterialAsset>(AssetGuid.format(AssetGuid.random()), {
|
|
66
|
+
kind: 'material',
|
|
67
|
+
passes: [
|
|
68
|
+
{
|
|
69
|
+
name: 'Forward',
|
|
70
|
+
program: { module: 'forgeax::default-unlit' },
|
|
71
|
+
renderState: { tags: { LightMode: 'Forward' }, queue: 2000 },
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
values: { baseColor: [1, 1, 1] },
|
|
75
|
+
});
|
|
76
|
+
if (!matResult.ok) throw new Error('material catalog failed');
|
|
77
|
+
const material = world.allocSharedRef('MaterialAsset', matResult.value);
|
|
78
|
+
return { world, assets, material };
|
|
91
79
|
}
|
|
92
80
|
|
|
93
81
|
function spawnPerspectiveCamera(world: World, z: number): EntityHandle {
|
|
@@ -126,18 +114,19 @@ function registerTriangle(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
|
126
114
|
v[i * 12 + 1] = positions[i * 3 + 1] as number;
|
|
127
115
|
v[i * 12 + 2] = positions[i * 3 + 2] as number;
|
|
128
116
|
}
|
|
129
|
-
|
|
117
|
+
const result = scene.assets.catalog<MeshAsset>(AssetGuid.format(AssetGuid.random()), {
|
|
130
118
|
kind: 'mesh',
|
|
131
119
|
vertices: v,
|
|
132
120
|
indices: new Uint16Array([0, 1, 2]),
|
|
133
121
|
attributes: { position: positions },
|
|
134
|
-
aabb: aabbFromPositions(positions),
|
|
135
122
|
submeshes: [
|
|
136
123
|
{ indexOffset: 0, indexCount: 3, vertexCount: 3, topology: 'triangle-list', materialSlot: 0 },
|
|
137
124
|
],
|
|
138
125
|
|
|
139
126
|
materialSlots: [{ slotName: 'Default' }],
|
|
140
127
|
});
|
|
128
|
+
if (!result.ok) throw new Error(`triangle mesh catalog failed: ${result.error.message}`);
|
|
129
|
+
return scene.world.allocSharedRef('MeshAsset', result.value);
|
|
141
130
|
}
|
|
142
131
|
|
|
143
132
|
/**
|
|
@@ -215,12 +204,11 @@ function registerCube(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
|
215
204
|
7,
|
|
216
205
|
4, // left
|
|
217
206
|
]);
|
|
218
|
-
|
|
207
|
+
const result = scene.assets.catalog<MeshAsset>(AssetGuid.format(AssetGuid.random()), {
|
|
219
208
|
kind: 'mesh',
|
|
220
209
|
vertices: v,
|
|
221
210
|
indices,
|
|
222
211
|
attributes: { position: positions },
|
|
223
|
-
aabb: aabbFromPositions(positions),
|
|
224
212
|
submeshes: [
|
|
225
213
|
{
|
|
226
214
|
indexOffset: 0,
|
|
@@ -233,6 +221,8 @@ function registerCube(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
|
233
221
|
|
|
234
222
|
materialSlots: [{ slotName: 'Default' }],
|
|
235
223
|
});
|
|
224
|
+
if (!result.ok) throw new Error(`cube mesh catalog failed: ${result.error.message}`);
|
|
225
|
+
return scene.world.allocSharedRef('MeshAsset', result.value);
|
|
236
226
|
}
|
|
237
227
|
|
|
238
228
|
/**
|
|
@@ -262,18 +252,19 @@ function registerSkinnedTriangle(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
|
262
252
|
skinIndex,
|
|
263
253
|
skinWeight,
|
|
264
254
|
};
|
|
265
|
-
|
|
255
|
+
const result = scene.assets.catalog<MeshAsset>(AssetGuid.format(AssetGuid.random()), {
|
|
266
256
|
kind: 'mesh',
|
|
267
257
|
vertices: v,
|
|
268
258
|
indices: new Uint16Array([0, 1, 2]),
|
|
269
259
|
attributes: attrs,
|
|
270
|
-
aabb: aabbFromPositions(positions),
|
|
271
260
|
submeshes: [
|
|
272
261
|
{ indexOffset: 0, indexCount: 3, vertexCount: 3, topology: 'triangle-list', materialSlot: 0 },
|
|
273
262
|
],
|
|
274
263
|
|
|
275
264
|
materialSlots: [{ slotName: 'Default' }],
|
|
276
265
|
});
|
|
266
|
+
if (!result.ok) throw new Error(`skinned mesh catalog failed: ${result.error.message}`);
|
|
267
|
+
return scene.world.allocSharedRef('MeshAsset', result.value);
|
|
277
268
|
}
|
|
278
269
|
|
|
279
270
|
function spawnMeshEntity(
|
|
@@ -498,7 +489,10 @@ describe('pickVertexOnEntity', () => {
|
|
|
498
489
|
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
499
490
|
propagateTransforms(scene.world);
|
|
500
491
|
|
|
501
|
-
const meshRes =
|
|
492
|
+
const meshRes = resolveAssetHandle<MeshAsset>(
|
|
493
|
+
scene.world,
|
|
494
|
+
toShared<'MeshAsset'>(mesh as unknown as number),
|
|
495
|
+
);
|
|
502
496
|
if (!meshRes.ok) throw new Error('resolve failed');
|
|
503
497
|
const position = meshRes.value.attributes.position as Float32Array;
|
|
504
498
|
const wm = readWorldMatrix(scene.world, entity)!;
|
|
@@ -535,7 +529,10 @@ describe('pickVertexOnEntity', () => {
|
|
|
535
529
|
const entity = spawnMeshEntity(scene, mesh, 0.1, 0.2, 0);
|
|
536
530
|
propagateTransforms(scene.world);
|
|
537
531
|
|
|
538
|
-
const meshRes =
|
|
532
|
+
const meshRes = resolveAssetHandle<MeshAsset>(
|
|
533
|
+
scene.world,
|
|
534
|
+
toShared<'MeshAsset'>(mesh as unknown as number),
|
|
535
|
+
);
|
|
539
536
|
if (!meshRes.ok) throw new Error('resolve failed');
|
|
540
537
|
const position = meshRes.value.attributes.position as Float32Array;
|
|
541
538
|
const wm = readWorldMatrix(scene.world, entity)!;
|
|
@@ -726,7 +723,10 @@ describe('pickVertexOnEntity', () => {
|
|
|
726
723
|
propagateTransforms(scene.world);
|
|
727
724
|
|
|
728
725
|
// Verify mesh is skinned
|
|
729
|
-
const meshRes =
|
|
726
|
+
const meshRes = resolveAssetHandle<MeshAsset>(
|
|
727
|
+
scene.world,
|
|
728
|
+
toShared<'MeshAsset'>(mesh as unknown as number),
|
|
729
|
+
);
|
|
730
730
|
expect(meshRes.ok).toBe(true);
|
|
731
731
|
if (!meshRes.ok) throw new Error('resolve failed');
|
|
732
732
|
const attrs = meshRes.value.attributes;
|
|
@@ -750,7 +750,10 @@ describe('pickVertexOnEntity', () => {
|
|
|
750
750
|
const entity = spawnMeshEntity(scene, mesh, 0.1, 0.2, 0);
|
|
751
751
|
propagateTransforms(scene.world);
|
|
752
752
|
|
|
753
|
-
const meshRes =
|
|
753
|
+
const meshRes = resolveAssetHandle<MeshAsset>(
|
|
754
|
+
scene.world,
|
|
755
|
+
toShared<'MeshAsset'>(mesh as unknown as number),
|
|
756
|
+
);
|
|
754
757
|
if (!meshRes.ok) throw new Error('resolve failed');
|
|
755
758
|
const position = meshRes.value.attributes.position as Float32Array;
|
|
756
759
|
const wm = readWorldMatrix(scene.world, entity)!;
|
|
@@ -872,7 +875,10 @@ describe('pickVertexOnEntity', () => {
|
|
|
872
875
|
// propagate must be called before pickVertexOnEntity
|
|
873
876
|
propagateTransforms(scene.world);
|
|
874
877
|
|
|
875
|
-
const meshRes =
|
|
878
|
+
const meshRes = resolveAssetHandle<MeshAsset>(
|
|
879
|
+
scene.world,
|
|
880
|
+
toShared<'MeshAsset'>(mesh as unknown as number),
|
|
881
|
+
);
|
|
876
882
|
if (!meshRes.ok) throw new Error('resolve failed');
|
|
877
883
|
const position = meshRes.value.attributes.position as Float32Array;
|
|
878
884
|
const wm = readWorldMatrix(scene.world, entity)!;
|
|
@@ -1014,7 +1020,7 @@ describe('w6: degradation input + builtin fallback', () => {
|
|
|
1014
1020
|
v[i * 12 + 1] = positions[i * 3 + 1] as number;
|
|
1015
1021
|
v[i * 12 + 2] = positions[i * 3 + 2] as number;
|
|
1016
1022
|
}
|
|
1017
|
-
|
|
1023
|
+
const result = scene.assets.catalog<MeshAsset>(AssetGuid.format(AssetGuid.random()), {
|
|
1018
1024
|
kind: 'mesh',
|
|
1019
1025
|
vertices: v,
|
|
1020
1026
|
indices: new Uint16Array([0, 1, 2, 3]),
|
|
@@ -1031,6 +1037,8 @@ describe('w6: degradation input + builtin fallback', () => {
|
|
|
1031
1037
|
|
|
1032
1038
|
materialSlots: [{ slotName: 'Default' }],
|
|
1033
1039
|
});
|
|
1040
|
+
if (!result.ok) throw new Error(`strip mesh catalog failed: ${result.error.message}`);
|
|
1041
|
+
return scene.world.allocSharedRef('MeshAsset', result.value);
|
|
1034
1042
|
}
|
|
1035
1043
|
|
|
1036
1044
|
it('strip submesh returns undefined (no crash, no approximate hit)', () => {
|
|
@@ -1062,7 +1070,7 @@ describe('w6: degradation input + builtin fallback', () => {
|
|
|
1062
1070
|
function registerUint16Position(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
1063
1071
|
const positions = new Uint16Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
1064
1072
|
const v = new Float32Array(3 * 12);
|
|
1065
|
-
|
|
1073
|
+
const result = scene.assets.catalog<MeshAsset>(AssetGuid.format(AssetGuid.random()), {
|
|
1066
1074
|
kind: 'mesh',
|
|
1067
1075
|
vertices: v,
|
|
1068
1076
|
indices: new Uint16Array([0, 1, 2]),
|
|
@@ -1079,11 +1087,13 @@ describe('w6: degradation input + builtin fallback', () => {
|
|
|
1079
1087
|
|
|
1080
1088
|
materialSlots: [{ slotName: 'Default' }],
|
|
1081
1089
|
});
|
|
1090
|
+
if (!result.ok) throw new Error(`u16 mesh catalog failed: ${result.error.message}`);
|
|
1091
|
+
return scene.world.allocSharedRef('MeshAsset', result.value);
|
|
1082
1092
|
}
|
|
1083
1093
|
|
|
1084
1094
|
function registerUndefinedPosition(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
1085
1095
|
const v = new Float32Array(3 * 12);
|
|
1086
|
-
|
|
1096
|
+
const result = scene.assets.catalog<MeshAsset>(AssetGuid.format(AssetGuid.random()), {
|
|
1087
1097
|
kind: 'mesh',
|
|
1088
1098
|
vertices: v,
|
|
1089
1099
|
indices: new Uint16Array([0, 1, 2]),
|
|
@@ -1100,6 +1110,8 @@ describe('w6: degradation input + builtin fallback', () => {
|
|
|
1100
1110
|
|
|
1101
1111
|
materialSlots: [{ slotName: 'Default' }],
|
|
1102
1112
|
});
|
|
1113
|
+
if (!result.ok) throw new Error(`no-position mesh catalog failed: ${result.error.message}`);
|
|
1114
|
+
return scene.world.allocSharedRef('MeshAsset', result.value);
|
|
1103
1115
|
}
|
|
1104
1116
|
|
|
1105
1117
|
it('Uint16Array position returns undefined (skip, no crash)', () => {
|
|
@@ -1136,7 +1148,7 @@ describe('w6: degradation input + builtin fallback', () => {
|
|
|
1136
1148
|
v[i * 12 + 1] = positions[i * 3 + 1] as number;
|
|
1137
1149
|
v[i * 12 + 2] = positions[i * 3 + 2] as number;
|
|
1138
1150
|
}
|
|
1139
|
-
|
|
1151
|
+
const result = scene.assets.catalog<MeshAsset>(AssetGuid.format(AssetGuid.random()), {
|
|
1140
1152
|
kind: 'mesh',
|
|
1141
1153
|
vertices: v,
|
|
1142
1154
|
attributes: { position: positions },
|
|
@@ -1152,6 +1164,8 @@ describe('w6: degradation input + builtin fallback', () => {
|
|
|
1152
1164
|
|
|
1153
1165
|
materialSlots: [{ slotName: 'Default' }],
|
|
1154
1166
|
});
|
|
1167
|
+
if (!result.ok) throw new Error(`indexless mesh catalog failed: ${result.error.message}`);
|
|
1168
|
+
return scene.world.allocSharedRef('MeshAsset', result.value);
|
|
1155
1169
|
}
|
|
1156
1170
|
|
|
1157
1171
|
it('non-indexed mesh hits vertices (every consecutive 3 vertices = one face)', () => {
|
|
@@ -1177,7 +1191,7 @@ describe('w6: degradation input + builtin fallback', () => {
|
|
|
1177
1191
|
function registerEmptyMesh(scene: Scene): Handle<'MeshAsset', 'shared'> {
|
|
1178
1192
|
const positions = new Float32Array(0);
|
|
1179
1193
|
const v = new Float32Array(0);
|
|
1180
|
-
|
|
1194
|
+
const result = scene.assets.catalog<MeshAsset>(AssetGuid.format(AssetGuid.random()), {
|
|
1181
1195
|
kind: 'mesh',
|
|
1182
1196
|
vertices: v,
|
|
1183
1197
|
attributes: { position: positions },
|
|
@@ -1193,6 +1207,8 @@ describe('w6: degradation input + builtin fallback', () => {
|
|
|
1193
1207
|
|
|
1194
1208
|
materialSlots: [{ slotName: 'Default' }],
|
|
1195
1209
|
});
|
|
1210
|
+
if (!result.ok) throw new Error(`empty mesh catalog failed: ${result.error.message}`);
|
|
1211
|
+
return scene.world.allocSharedRef('MeshAsset', result.value);
|
|
1196
1212
|
}
|
|
1197
1213
|
|
|
1198
1214
|
it('0 vertices returns undefined (no crash)', () => {
|
|
@@ -1229,7 +1245,7 @@ describe('w6: degradation input + builtin fallback', () => {
|
|
|
1229
1245
|
v[i * 12 + 1] = positions[i * 3 + 1] as number;
|
|
1230
1246
|
v[i * 12 + 2] = positions[i * 3 + 2] as number;
|
|
1231
1247
|
}
|
|
1232
|
-
|
|
1248
|
+
const result = scene.assets.catalog<MeshAsset>(AssetGuid.format(AssetGuid.random()), {
|
|
1233
1249
|
kind: 'mesh',
|
|
1234
1250
|
vertices: v,
|
|
1235
1251
|
indices: new Uint16Array([0, 1, 2]),
|
|
@@ -1246,6 +1262,8 @@ describe('w6: degradation input + builtin fallback', () => {
|
|
|
1246
1262
|
|
|
1247
1263
|
materialSlots: [{ slotName: 'Default' }],
|
|
1248
1264
|
});
|
|
1265
|
+
if (!result.ok) throw new Error(`NaN mesh catalog failed: ${result.error.message}`);
|
|
1266
|
+
return scene.world.allocSharedRef('MeshAsset', result.value);
|
|
1249
1267
|
}
|
|
1250
1268
|
|
|
1251
1269
|
it('NaN vertex coordinates excluded, valid vertices still hit (no NaN propagation)', () => {
|
|
@@ -1276,7 +1294,7 @@ describe('w6: degradation input + builtin fallback', () => {
|
|
|
1276
1294
|
v[i * 12 + 1] = positions[i * 3 + 1] as number;
|
|
1277
1295
|
v[i * 12 + 2] = positions[i * 3 + 2] as number;
|
|
1278
1296
|
}
|
|
1279
|
-
const
|
|
1297
|
+
const result = scene.assets.catalog<MeshAsset>(AssetGuid.format(AssetGuid.random()), {
|
|
1280
1298
|
kind: 'mesh',
|
|
1281
1299
|
vertices: v,
|
|
1282
1300
|
indices: new Uint16Array([0, 1, 2]),
|
|
@@ -1293,6 +1311,8 @@ describe('w6: degradation input + builtin fallback', () => {
|
|
|
1293
1311
|
|
|
1294
1312
|
materialSlots: [{ slotName: 'Default' }],
|
|
1295
1313
|
});
|
|
1314
|
+
if (!result.ok) throw new Error('all-NaN mesh catalog failed');
|
|
1315
|
+
const mesh = scene.world.allocSharedRef('MeshAsset', result.value);
|
|
1296
1316
|
const entity = spawnMeshEntity(scene, mesh, 0, 0, 0);
|
|
1297
1317
|
const hit = runPickVertexOnEntity(scene.world, camera, VP / 2, VP / 2, VP, VP, entity) as
|
|
1298
1318
|
| VertexHit
|
|
@@ -1301,17 +1321,16 @@ describe('w6: degradation input + builtin fallback', () => {
|
|
|
1301
1321
|
});
|
|
1302
1322
|
});
|
|
1303
1323
|
|
|
1304
|
-
// ── AC-07:
|
|
1324
|
+
// ── AC-07: builtin mesh without AABB → walk-all-vertices fallback ──
|
|
1305
1325
|
|
|
1306
|
-
describe('AC-07:
|
|
1307
|
-
it('
|
|
1326
|
+
describe('AC-07: builtin mesh without AABB — walk-all-vertices fallback', () => {
|
|
1327
|
+
it('BUILTIN_TRIANGLE (no AABB) returns a valid hit via full vertex walk', () => {
|
|
1308
1328
|
const scene = makeScene();
|
|
1309
1329
|
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1310
|
-
const mesh = registerPrimitiveWithoutAabb(scene, 'triangle');
|
|
1311
1330
|
const entity = scene.world
|
|
1312
1331
|
.spawn(
|
|
1313
1332
|
{ component: Transform, data: translateTransform(0, 0, 0) },
|
|
1314
|
-
{ component: MeshFilter, data: { assetHandle:
|
|
1333
|
+
{ component: MeshFilter, data: { assetHandle: toShared<'MeshAsset'>(1) } },
|
|
1315
1334
|
{ component: MeshRenderer, data: { materials: [scene.material] } },
|
|
1316
1335
|
)
|
|
1317
1336
|
.unwrap();
|
|
@@ -1327,14 +1346,13 @@ describe('w6: degradation input + builtin fallback', () => {
|
|
|
1327
1346
|
}
|
|
1328
1347
|
});
|
|
1329
1348
|
|
|
1330
|
-
it('
|
|
1349
|
+
it('BUILTIN_CUBE (no AABB) returns valid vertex hits', () => {
|
|
1331
1350
|
const scene = makeScene();
|
|
1332
1351
|
const camera = spawnPerspectiveCamera(scene.world, 5);
|
|
1333
|
-
const mesh = registerPrimitiveWithoutAabb(scene, 'cube');
|
|
1334
1352
|
const entity = scene.world
|
|
1335
1353
|
.spawn(
|
|
1336
1354
|
{ component: Transform, data: translateTransform(0, 0, 0) },
|
|
1337
|
-
{ component: MeshFilter, data: { assetHandle:
|
|
1355
|
+
{ component: MeshFilter, data: { assetHandle: toShared<'MeshAsset'>(2) } },
|
|
1338
1356
|
{ component: MeshRenderer, data: { materials: [scene.material] } },
|
|
1339
1357
|
)
|
|
1340
1358
|
.unwrap();
|
|
@@ -1517,12 +1535,11 @@ describe('w7: pickVertex full-scene', () => {
|
|
|
1517
1535
|
v[i * 12 + 1] = positions[i * 3 + 1] as number;
|
|
1518
1536
|
v[i * 12 + 2] = positions[i * 3 + 2] as number;
|
|
1519
1537
|
}
|
|
1520
|
-
|
|
1538
|
+
const result = scene.assets.catalog<MeshAsset>(AssetGuid.format(AssetGuid.random()), {
|
|
1521
1539
|
kind: 'mesh',
|
|
1522
1540
|
vertices: v,
|
|
1523
1541
|
indices: new Uint16Array([0, 1, 2]),
|
|
1524
1542
|
attributes: { position: positions },
|
|
1525
|
-
aabb: aabbFromPositions(positions),
|
|
1526
1543
|
submeshes: [
|
|
1527
1544
|
{
|
|
1528
1545
|
indexOffset: 0,
|
|
@@ -1535,6 +1552,8 @@ describe('w7: pickVertex full-scene', () => {
|
|
|
1535
1552
|
|
|
1536
1553
|
materialSlots: [{ slotName: 'Default' }],
|
|
1537
1554
|
});
|
|
1555
|
+
if (!result.ok) throw new Error(`small triangle catalog failed: ${result.error.message}`);
|
|
1556
|
+
return scene.world.allocSharedRef('MeshAsset', result.value);
|
|
1538
1557
|
}
|
|
1539
1558
|
|
|
1540
1559
|
it('entity far from ray is excluded by AABB coarse cull', () => {
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// package (AC-203), so these integration tests live in the downstream picking
|
|
7
7
|
// package that depends on runtime.
|
|
8
8
|
|
|
9
|
+
import { AssetRegistry } from '@forgeax/engine-assets-runtime';
|
|
9
10
|
import { type EntityHandle, World } from '@forgeax/engine-ecs';
|
|
11
|
+
import { AssetGuid } from '@forgeax/engine-pack/guid';
|
|
10
12
|
import {
|
|
11
13
|
CAMERA_PROJECTION_ORTHOGRAPHIC,
|
|
12
14
|
CAMERA_PROJECTION_PERSPECTIVE,
|
|
@@ -15,13 +17,13 @@ import {
|
|
|
15
17
|
MeshRenderer,
|
|
16
18
|
} from '@forgeax/engine-render';
|
|
17
19
|
import { ChildOf, propagateTransforms, Transform } from '@forgeax/engine-scene';
|
|
18
|
-
import type { Handle } from '@forgeax/engine-types';
|
|
19
|
-
|
|
20
|
+
import type { Handle, MaterialAsset, MeshAsset } from '@forgeax/engine-types';
|
|
20
21
|
import { describe, expect, it } from 'vitest';
|
|
21
22
|
import { type PickHit, pick } from '../pick';
|
|
22
23
|
import { PickError } from '../pick-errors';
|
|
23
24
|
import { pickVertex, pickVertexOnEntity } from '../pick-vertex';
|
|
24
25
|
import { viewportToWorld } from '../viewport-to-world';
|
|
26
|
+
import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
|
|
25
27
|
|
|
26
28
|
// --- from pick.test.ts ---
|
|
27
29
|
// pick.test.ts — feat-20260529-picking-raycasting-screen-to-entity M3 / w12 (TDD red).
|
|
@@ -30,10 +32,14 @@ import { viewportToWorld } from '../viewport-to-world';
|
|
|
30
32
|
// pick(world, cameraEntity, screenX, screenY, viewportWidth, viewportHeight)
|
|
31
33
|
// -> PickHit | undefined
|
|
32
34
|
//
|
|
33
|
-
// The deterministic scene is built with a bare `new World()`
|
|
34
|
-
//
|
|
35
|
+
// The deterministic scene is built with a bare `new World()` + a real
|
|
36
|
+
// `AssetRegistry` (the `assets` param introduced by the 2026-05-29 replan: the
|
|
37
|
+
// ray-AABB test needs `MeshAsset.aabb`, which lives ONLY in the AssetRegistry,
|
|
38
|
+
// never on a world column). A full `createRenderer` is intentionally NOT used —
|
|
35
39
|
// it requires a live WebGPU device unavailable in the `pnpm test:unit` project,
|
|
36
|
-
// and `pick` only consumes
|
|
40
|
+
// and `pick` only consumes an `AssetRegistry`, not the renderer. The registry
|
|
41
|
+
// instance IS `renderer.assets` at the demo call site (w16), so the surface
|
|
42
|
+
// under test is identical.
|
|
37
43
|
//
|
|
38
44
|
// Coverage (all pick acceptance criteria):
|
|
39
45
|
// (AC-06) nearest hit wins — two boxes along the ray, the closer one returns
|
|
@@ -97,7 +103,7 @@ function translateTransform(
|
|
|
97
103
|
* `vertices` must be a multiple of the 12-float interleaved stride; a single 3-vertex
|
|
98
104
|
* triangle (36 floats) satisfies the gate while the position attribute drives the AABB.
|
|
99
105
|
*/
|
|
100
|
-
function registerBox(world: World): Handle<'MeshAsset', 'shared'> {
|
|
106
|
+
function registerBox(world: World, assets: AssetRegistry): Handle<'MeshAsset', 'shared'> {
|
|
101
107
|
const vertices = new Float32Array([
|
|
102
108
|
0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1,
|
|
103
109
|
0, 0, 0, 0,
|
|
@@ -107,7 +113,9 @@ function registerBox(world: World): Handle<'MeshAsset', 'shared'> {
|
|
|
107
113
|
-0.5, -0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, -0.5, -0.5, -0.5, 0.5, 0.5, -0.5,
|
|
108
114
|
0.5, 0.5, 0.5, 0.5, -0.5, 0.5, 0.5,
|
|
109
115
|
]);
|
|
110
|
-
|
|
116
|
+
// catalog computes the local-space AABB (withMeshAabb); mint the augmented
|
|
117
|
+
// payload on the world so resolveAssetHandle (used by pick) reads .aabb.
|
|
118
|
+
const result = assets.catalog<MeshAsset>(AssetGuid.format(AssetGuid.random()), {
|
|
111
119
|
kind: 'mesh',
|
|
112
120
|
vertices,
|
|
113
121
|
indices: new Uint16Array([0, 1, 2]),
|
|
@@ -123,12 +131,13 @@ function registerBox(world: World): Handle<'MeshAsset', 'shared'> {
|
|
|
123
131
|
],
|
|
124
132
|
|
|
125
133
|
materialSlots: [{ slotName: 'Default' }],
|
|
126
|
-
aabb: new Float32Array([-0.5, -0.5, -0.5, 0.5, 0.5, 0.5]),
|
|
127
134
|
});
|
|
135
|
+
if (!result.ok) throw new Error('mesh catalog failed');
|
|
136
|
+
return world.allocSharedRef('MeshAsset', result.value);
|
|
128
137
|
}
|
|
129
138
|
|
|
130
|
-
function registerMaterial(world: World): Handle<'MaterialAsset', 'shared'> {
|
|
131
|
-
|
|
139
|
+
function registerMaterial(world: World, assets: AssetRegistry): Handle<'MaterialAsset', 'shared'> {
|
|
140
|
+
const result = assets.catalog<MaterialAsset>(AssetGuid.format(AssetGuid.random()), {
|
|
132
141
|
kind: 'material',
|
|
133
142
|
passes: [
|
|
134
143
|
{
|
|
@@ -139,19 +148,23 @@ function registerMaterial(world: World): Handle<'MaterialAsset', 'shared'> {
|
|
|
139
148
|
],
|
|
140
149
|
values: { baseColor: [1, 1, 1] },
|
|
141
150
|
});
|
|
151
|
+
if (!result.ok) throw new Error('material catalog failed');
|
|
152
|
+
return world.allocSharedRef('MaterialAsset', result.value);
|
|
142
153
|
}
|
|
143
154
|
|
|
144
155
|
interface Scene {
|
|
145
156
|
world: World;
|
|
157
|
+
assets: AssetRegistry;
|
|
146
158
|
mesh: Handle<'MeshAsset', 'shared'>;
|
|
147
159
|
material: Handle<'MaterialAsset', 'shared'>;
|
|
148
160
|
}
|
|
149
161
|
|
|
150
162
|
function makeScene(): Scene {
|
|
151
163
|
const world = new World();
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
164
|
+
const assets = new AssetRegistry(makeMockShaderRegistry());
|
|
165
|
+
const mesh = registerBox(world, assets);
|
|
166
|
+
const material = registerMaterial(world, assets);
|
|
167
|
+
return { world, assets, mesh, material };
|
|
155
168
|
}
|
|
156
169
|
|
|
157
170
|
/** Spawn a perspective camera at (x,y,z) looking down -Z (identity rotation). */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
|
|
1
2
|
import { type EntityHandle, World } from '@forgeax/engine-ecs';
|
|
2
|
-
import { createPrimitiveMesh } from '@forgeax/engine-geometry';
|
|
3
3
|
import {
|
|
4
4
|
CAMERA_PROJECTION_PERSPECTIVE,
|
|
5
5
|
Camera,
|
|
@@ -8,9 +8,9 @@ import {
|
|
|
8
8
|
Visibility,
|
|
9
9
|
VisibilityStateValue,
|
|
10
10
|
} from '@forgeax/engine-render';
|
|
11
|
-
import { extractFrames } from '@forgeax/engine-render/internal';
|
|
12
11
|
import { propagateTransforms, Transform } from '@forgeax/engine-scene';
|
|
13
12
|
import { describe, expect, it } from 'vitest';
|
|
13
|
+
import { extractFrames } from '../../../render/src/render-system-extract';
|
|
14
14
|
import { pick } from '../pick';
|
|
15
15
|
|
|
16
16
|
const VIEWPORT = 600;
|
|
@@ -49,11 +49,10 @@ describe('visibility picking boundary', () => {
|
|
|
49
49
|
it('keeps pick results unchanged when the render candidate is hidden', () => {
|
|
50
50
|
const world = new World();
|
|
51
51
|
const camera = spawnCamera(world);
|
|
52
|
-
const mesh = world.internSharedRef('MeshAsset', createPrimitiveMesh('cube').unwrap());
|
|
53
52
|
const entity = world
|
|
54
53
|
.spawn(
|
|
55
54
|
{ component: Transform, data: transform([0, 0, 0]) },
|
|
56
|
-
{ component: MeshFilter, data: { assetHandle:
|
|
55
|
+
{ component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
|
|
57
56
|
{ component: MeshRenderer, data: {} },
|
|
58
57
|
{ component: Visibility, data: { state: VisibilityStateValue.visible } },
|
|
59
58
|
)
|
package/src/pick-vertex.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// - { limit: N } → VertexHit[]
|
|
16
16
|
//
|
|
17
17
|
// Reuses pick()'s full skeleton (camera validation / view=invert(world) / projection branch /
|
|
18
|
-
// screenToRay /
|
|
18
|
+
// screenToRay / resolveAssetHandle / Transform.world / AABB coarse cull), then for each
|
|
19
19
|
// triangle-list submesh that passes the AABB test, iterates every triangle and collects
|
|
20
20
|
// vertex candidates via rayTriangleIntersects.
|
|
21
21
|
//
|
|
@@ -43,13 +43,13 @@
|
|
|
43
43
|
// Finding 9 (behind flag) / Finding 10 (isSkinned);
|
|
44
44
|
// plan-tasks.json w5/w8 acceptanceCheck.
|
|
45
45
|
|
|
46
|
+
import { resolveAssetHandle } from '@forgeax/engine-assets-runtime';
|
|
46
47
|
import type { EntityHandle, World } from '@forgeax/engine-ecs';
|
|
47
48
|
import { mat4, ray, type Vec3Like, vec2, vec3 } from '@forgeax/engine-math';
|
|
48
49
|
import { MeshFilter, MeshRenderer } from '@forgeax/engine-render';
|
|
49
50
|
import { Transform } from '@forgeax/engine-scene';
|
|
50
51
|
import type { MeshAsset } from '@forgeax/engine-types';
|
|
51
52
|
import { toShared } from '@forgeax/engine-types';
|
|
52
|
-
import { resolvePickingAsset } from './asset-port';
|
|
53
53
|
import { computeScreenRay, readWorldMatrix } from './pick-core';
|
|
54
54
|
|
|
55
55
|
// ── types ────────────────────────────────────────────────────────────────
|
|
@@ -236,7 +236,7 @@ function collectVertexHits(
|
|
|
236
236
|
if (!mfRes.ok) {
|
|
237
237
|
return [];
|
|
238
238
|
}
|
|
239
|
-
const meshRes =
|
|
239
|
+
const meshRes = resolveAssetHandle<MeshAsset>(
|
|
240
240
|
world,
|
|
241
241
|
toShared<'MeshAsset'>(mfRes.value.assetHandle as unknown as number),
|
|
242
242
|
);
|
|
@@ -546,7 +546,7 @@ export function pickVertex(
|
|
|
546
546
|
for (const row of query) {
|
|
547
547
|
const assetHandleRaw = Math.round(row.get(MeshFilter).assetHandle as number);
|
|
548
548
|
if (assetHandleRaw === 0) continue;
|
|
549
|
-
const meshRes =
|
|
549
|
+
const meshRes = resolveAssetHandle<MeshAsset>(world, toShared<'MeshAsset'>(assetHandleRaw));
|
|
550
550
|
if (!meshRes.ok) continue;
|
|
551
551
|
|
|
552
552
|
// read entity (mirrors pick.ts:190)
|
package/src/pick.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Mesh AABB source (feat-20260614 M8, D-15/D-18): the ray-AABB test needs each
|
|
10
10
|
// mesh's local-space `MeshAsset.aabb`, resolved from the entity's `MeshFilter`
|
|
11
|
-
// handle via `
|
|
11
|
+
// handle via `resolveAssetHandle<MeshAsset>(world, handle)` (two-tier builtin /
|
|
12
12
|
// world.sharedRefs dispatch). The registry no longer holds handles, so `pick`
|
|
13
13
|
// takes no `AssetRegistry` -- it resolves entirely World-side. Keeping `pick` a
|
|
14
14
|
// free function (rather than a `World` method) preserves the layering: `World`
|
|
@@ -32,13 +32,13 @@
|
|
|
32
32
|
// Related: requirements in-scope #5/#6/#7 + AC-05..AC-11; plan-strategy D-3 / D-6 / 5.3;
|
|
33
33
|
// research Finding 4 (local->world AABB) + Finding 5 (entity-id via query rows).
|
|
34
34
|
|
|
35
|
+
import { resolveAssetHandle } from '@forgeax/engine-assets-runtime';
|
|
35
36
|
import type { EntityHandle, World } from '@forgeax/engine-ecs';
|
|
36
37
|
import { box3, ray, type Vec3Like, vec3 } from '@forgeax/engine-math';
|
|
37
38
|
import { MeshFilter, MeshRenderer } from '@forgeax/engine-render';
|
|
38
39
|
import { Transform } from '@forgeax/engine-scene';
|
|
39
40
|
import type { MeshAsset } from '@forgeax/engine-types';
|
|
40
41
|
import { toShared } from '@forgeax/engine-types';
|
|
41
|
-
import { resolvePickingAsset } from './asset-port';
|
|
42
42
|
import { computeScreenRay, readWorldMatrix } from './pick-core';
|
|
43
43
|
|
|
44
44
|
/**
|
|
@@ -103,7 +103,7 @@ export function pick(
|
|
|
103
103
|
for (const row of query) {
|
|
104
104
|
const assetHandleRaw = Math.round(row.get(MeshFilter).assetHandle as number);
|
|
105
105
|
if (assetHandleRaw === 0) continue;
|
|
106
|
-
const meshRes =
|
|
106
|
+
const meshRes = resolveAssetHandle<MeshAsset>(world, toShared<'MeshAsset'>(assetHandleRaw));
|
|
107
107
|
if (!meshRes.ok) continue;
|
|
108
108
|
const localAabb = meshRes.value.aabb;
|
|
109
109
|
if (localAabb === undefined) continue;
|
package/dist/asset-port.d.ts
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import type { World } from '@forgeax/engine-ecs';
|
|
2
|
-
import type { Asset, Handle, Result } from '@forgeax/engine-types';
|
|
3
|
-
export type PickingAssetResolutionError = ReturnType<World['sharedRefs']['resolve']> extends Result<unknown, infer E> ? E : never;
|
|
4
|
-
/** Resolve a picking payload through the World shared-ref owner. */
|
|
5
|
-
export declare function resolvePickingAsset<T extends Asset>(world: World, handle: Handle<string, 'shared'>): Result<T, PickingAssetResolutionError>;
|
|
6
|
-
//# sourceMappingURL=asset-port.d.ts.map
|
package/dist/asset-port.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"asset-port.d.ts","sourceRoot":"","sources":["../src/asset-port.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAEnE,MAAM,MAAM,2BAA2B,GACrC,UAAU,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,SAAS,CAAC,CAAC,SAAS,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;AAE1F,oEAAoE;AACpE,wBAAgB,mBAAmB,CAAC,CAAC,SAAS,KAAK,EACjD,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,GAC/B,MAAM,CAAC,CAAC,EAAE,2BAA2B,CAAC,CAExC"}
|
package/src/asset-port.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import type { World } from '@forgeax/engine-ecs';
|
|
2
|
-
import type { Asset, Handle, Result } from '@forgeax/engine-types';
|
|
3
|
-
|
|
4
|
-
export type PickingAssetResolutionError =
|
|
5
|
-
ReturnType<World['sharedRefs']['resolve']> extends Result<unknown, infer E> ? E : never;
|
|
6
|
-
|
|
7
|
-
/** Resolve a picking payload through the World shared-ref owner. */
|
|
8
|
-
export function resolvePickingAsset<T extends Asset>(
|
|
9
|
-
world: World,
|
|
10
|
-
handle: Handle<string, 'shared'>,
|
|
11
|
-
): Result<T, PickingAssetResolutionError> {
|
|
12
|
-
return world.sharedRefs.resolve<string, T>(handle) as Result<T, PickingAssetResolutionError>;
|
|
13
|
-
}
|