@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,593 @@
|
|
|
1
|
+
// pick-vertex.ts — per-entity + full-scene vertex-level picking (feat-20260630-vertex-snapping-picking).
|
|
2
|
+
//
|
|
3
|
+
// `pickVertexOnEntity(world, cameraEntity, screenX, screenY, vpW, vpH, entity, options?)`
|
|
4
|
+
// returns the vertex(es) on a single entity nearest to the supplied screen coordinate.
|
|
5
|
+
//
|
|
6
|
+
// `pickVertex(world, cameraEntity, screenX, screenY, vpW, vpH, options?)`
|
|
7
|
+
// walks all renderable archetypes (AABB coarse cull), calls pickVertexOnEntity on each
|
|
8
|
+
// ray-intersecting entity, and returns the globally-nearest vertex hits sorted by screenDist.
|
|
9
|
+
//
|
|
10
|
+
// M2 scope (w5): single-entity query with overloaded three-state return (D-2).
|
|
11
|
+
// M3 scope (w8): degradation branches (D-4/D-5/AC-07/R-3) + pickVertex full-scene (R-2).
|
|
12
|
+
//
|
|
13
|
+
// Overload contract:
|
|
14
|
+
// - No options / no limit → VertexHit | undefined
|
|
15
|
+
// - { limit: N } → VertexHit[]
|
|
16
|
+
//
|
|
17
|
+
// Reuses pick()'s full skeleton (camera validation / view=invert(world) / projection branch /
|
|
18
|
+
// screenToRay / world.sharedRefs.resolve / Transform.world / AABB coarse cull), then for each
|
|
19
|
+
// triangle-list submesh that passes the AABB test, iterates every triangle and collects
|
|
20
|
+
// vertex candidates via rayTriangleIntersects.
|
|
21
|
+
//
|
|
22
|
+
// Key design points:
|
|
23
|
+
// - D-7: worldPos uses Vec3Like (not branded Vec3), mirroring PickHit.point.
|
|
24
|
+
// - D-8: reuses PickError with ZERO new error codes — the sole throw remains
|
|
25
|
+
// camera-component-missing; all other miss conditions return undefined / [].
|
|
26
|
+
// - D-9: caller MUST propagateTransforms(world) before calling — the function reads
|
|
27
|
+
// Transform.world directly.
|
|
28
|
+
// - AC-08: deformed = isSkinned (attributes.skinIndex && attributes.skinWeight both
|
|
29
|
+
// defined, SSOT at asset-registry.ts:1417-1418).
|
|
30
|
+
// - R-3: behind-camera vertices (worldToScreen returns behind=true) are excluded.
|
|
31
|
+
// - D-4: position three-branch narrow (Float32Array → use; ArrayBuffer → new Float32Array;
|
|
32
|
+
// Uint16Array / undefined → skip mesh), mirrors computeAABB asset-registry.ts:1898-1929.
|
|
33
|
+
// - D-5: only triangle-list topology participates; triangle-strip / line-list /
|
|
34
|
+
// line-strip / point-list → skip submesh.
|
|
35
|
+
// - AC-07: builtin mesh with aabb===undefined → fallback to walk-all-vertices
|
|
36
|
+
// (not continue like pick.ts:184).
|
|
37
|
+
//
|
|
38
|
+
// Related: requirements AC-01/AC-02/AC-03/AC-04/AC-05/AC-07/AC-08/AC-09/AC-10/AC-13;
|
|
39
|
+
// plan-strategy D-2/D-3/D-4/D-5/D-7/D-8/D-9 §4 R-2/R-3;
|
|
40
|
+
// research Finding 1 (reuse pick skeleton) / Finding 2 (PickError) /
|
|
41
|
+
// Finding 4 (computeAABB three-branch) / Finding 5 (builtin withoutAabb) /
|
|
42
|
+
// Finding 6 (non-indexed sequence) / Finding 8 (Transform.world) /
|
|
43
|
+
// Finding 9 (behind flag) / Finding 10 (isSkinned);
|
|
44
|
+
// plan-tasks.json w5/w8 acceptanceCheck.
|
|
45
|
+
|
|
46
|
+
import type { EntityHandle, World } from '@forgeax/engine-ecs';
|
|
47
|
+
import { mat4, ray, type Vec3Like, vec2, vec3 } from '@forgeax/engine-math';
|
|
48
|
+
import { MeshFilter, MeshRenderer } from '@forgeax/engine-render';
|
|
49
|
+
import { Transform } from '@forgeax/engine-scene';
|
|
50
|
+
import type { MeshAsset } from '@forgeax/engine-types';
|
|
51
|
+
import { toShared } from '@forgeax/engine-types';
|
|
52
|
+
import { resolvePickingAsset } from './asset-port';
|
|
53
|
+
import { computeScreenRay, readWorldMatrix } from './pick-core';
|
|
54
|
+
|
|
55
|
+
// ── types ────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Result of a successful vertex pick: the mesh vertex nearest to the
|
|
59
|
+
* supplied screen-space coordinate.
|
|
60
|
+
*
|
|
61
|
+
* Field set:
|
|
62
|
+
* - `entity` — the entity this vertex belongs to.
|
|
63
|
+
* - `vertexIndex` — index into the vertex position buffer (0-based).
|
|
64
|
+
* - `worldPos` — world-space position of the vertex (Vec3Like; rest-pose when
|
|
65
|
+
* deformed=true). D-7: Vec3Like avoids math brand cast lint in
|
|
66
|
+
* the runtime package.
|
|
67
|
+
* - `screenDist` — screen-space pixel distance from the query coordinate to the
|
|
68
|
+
* projected vertex position (non-negative).
|
|
69
|
+
* - `worldDist` — perpendicular 3D distance from the vertex world position to
|
|
70
|
+
* the pick ray (non-negative). Orthogonal counterpart to screenDist.
|
|
71
|
+
* - `deformed` — true when the mesh is skinned (skinIndex + skinWeight attributes
|
|
72
|
+
* both present), indicating worldPos reflects rest-pose, not GPU
|
|
73
|
+
* skinning output.
|
|
74
|
+
*/
|
|
75
|
+
export interface VertexHit {
|
|
76
|
+
readonly entity: EntityHandle;
|
|
77
|
+
readonly vertexIndex: number;
|
|
78
|
+
readonly worldPos: Vec3Like;
|
|
79
|
+
readonly screenDist: number;
|
|
80
|
+
readonly worldDist: number;
|
|
81
|
+
readonly deformed: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── helpers ──────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/** Scratch Vec2 for worldToScreen calls (reused across invocations). */
|
|
87
|
+
const _scratchVec2 = vec2.create();
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Compute the perpendicular distance from a point to a ray in 3D.
|
|
91
|
+
* rayDir is assumed normalized; returns |(P - O) x D|.
|
|
92
|
+
*/
|
|
93
|
+
function pointToRayDist(
|
|
94
|
+
px: number,
|
|
95
|
+
py: number,
|
|
96
|
+
pz: number,
|
|
97
|
+
ox: number,
|
|
98
|
+
oy: number,
|
|
99
|
+
oz: number,
|
|
100
|
+
dx: number,
|
|
101
|
+
dy: number,
|
|
102
|
+
dz: number,
|
|
103
|
+
): number {
|
|
104
|
+
const ex = px - ox;
|
|
105
|
+
const ey = py - oy;
|
|
106
|
+
const ez = pz - oz;
|
|
107
|
+
const cx = ey * dz - ez * dy;
|
|
108
|
+
const cy = ez * dx - ex * dz;
|
|
109
|
+
const cz = ex * dy - ey * dx;
|
|
110
|
+
return Math.sqrt(cx * cx + cy * cy + cz * cz);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Test whether a ray intersects a local-space AABB transformed by a world matrix.
|
|
115
|
+
* Returns false when the ray misses the box; returns true when the box is
|
|
116
|
+
* inverted (empty) — caller falls through to walk-all-vertices (AC-07).
|
|
117
|
+
*
|
|
118
|
+
* Extracted from collectVertexHits and pickVertex (review I-3, ~48 lines x2).
|
|
119
|
+
*/
|
|
120
|
+
function rayHitsWorldAabb(
|
|
121
|
+
r: Float32Array,
|
|
122
|
+
localAabb: Float32Array,
|
|
123
|
+
worldMat: mat4.Mat4Like,
|
|
124
|
+
): boolean {
|
|
125
|
+
// skip inverted-infinity empty box — fall through to walk-all-vertices
|
|
126
|
+
if ((localAabb[0] as number) > (localAabb[3] as number)) return true;
|
|
127
|
+
|
|
128
|
+
const corners = [
|
|
129
|
+
[localAabb[0] as number, localAabb[1] as number, localAabb[2] as number],
|
|
130
|
+
[localAabb[3] as number, localAabb[1] as number, localAabb[2] as number],
|
|
131
|
+
[localAabb[0] as number, localAabb[4] as number, localAabb[2] as number],
|
|
132
|
+
[localAabb[3] as number, localAabb[4] as number, localAabb[2] as number],
|
|
133
|
+
[localAabb[0] as number, localAabb[1] as number, localAabb[5] as number],
|
|
134
|
+
[localAabb[3] as number, localAabb[1] as number, localAabb[5] as number],
|
|
135
|
+
[localAabb[0] as number, localAabb[4] as number, localAabb[5] as number],
|
|
136
|
+
[localAabb[3] as number, localAabb[4] as number, localAabb[5] as number],
|
|
137
|
+
];
|
|
138
|
+
let minX = Infinity,
|
|
139
|
+
minY = Infinity,
|
|
140
|
+
minZ = Infinity;
|
|
141
|
+
let maxX = -Infinity,
|
|
142
|
+
maxY = -Infinity,
|
|
143
|
+
maxZ = -Infinity;
|
|
144
|
+
const tmpP = vec3.create();
|
|
145
|
+
for (const c of corners) {
|
|
146
|
+
mat4.transformPoint(tmpP, worldMat, c as unknown as Vec3Like);
|
|
147
|
+
const cx = tmpP[0] as number;
|
|
148
|
+
const cy = tmpP[1] as number;
|
|
149
|
+
const cz = tmpP[2] as number;
|
|
150
|
+
if (cx < minX) minX = cx;
|
|
151
|
+
if (cy < minY) minY = cy;
|
|
152
|
+
if (cz < minZ) minZ = cz;
|
|
153
|
+
if (cx > maxX) maxX = cx;
|
|
154
|
+
if (cy > maxY) maxY = cy;
|
|
155
|
+
if (cz > maxZ) maxZ = cz;
|
|
156
|
+
}
|
|
157
|
+
const worldAabb = new Float32Array(6);
|
|
158
|
+
worldAabb[0] = minX;
|
|
159
|
+
worldAabb[1] = minY;
|
|
160
|
+
worldAabb[2] = minZ;
|
|
161
|
+
worldAabb[3] = maxX;
|
|
162
|
+
worldAabb[4] = maxY;
|
|
163
|
+
worldAabb[5] = maxZ;
|
|
164
|
+
|
|
165
|
+
const aabbResult = ray.rayAabbIntersects(
|
|
166
|
+
r,
|
|
167
|
+
worldAabb as unknown as import('@forgeax/engine-math').box3.Box3Like,
|
|
168
|
+
);
|
|
169
|
+
return aabbResult.hit;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Narrow the position attribute per the three-branch contract (D-4),
|
|
174
|
+
* mirrors computeAABB asset-registry.ts:1898-1908.
|
|
175
|
+
* Returns the Float32Array position data, or undefined when the position
|
|
176
|
+
* is Uint16Array / undefined / too short (skip this mesh).
|
|
177
|
+
*/
|
|
178
|
+
function narrowPosition(
|
|
179
|
+
positionAttr: ArrayBuffer | Float32Array | Uint16Array | undefined,
|
|
180
|
+
): Float32Array | undefined {
|
|
181
|
+
if (positionAttr instanceof Float32Array) {
|
|
182
|
+
return positionAttr;
|
|
183
|
+
}
|
|
184
|
+
if (positionAttr instanceof ArrayBuffer) {
|
|
185
|
+
return new Float32Array(positionAttr);
|
|
186
|
+
}
|
|
187
|
+
// Uint16Array or undefined → skip (no usable float-coordinate vertex data)
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── internal: per-entity vertex hit collection (extracted for pickVertex reuse) ──
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Core per-entity vertex hit collection.
|
|
195
|
+
* Returns all vertex candidates for one entity, unsorted.
|
|
196
|
+
* Caller sorts and applies limit.
|
|
197
|
+
*/
|
|
198
|
+
function collectVertexHits(
|
|
199
|
+
world: World,
|
|
200
|
+
cameraEntity: EntityHandle,
|
|
201
|
+
screenX: number,
|
|
202
|
+
screenY: number,
|
|
203
|
+
viewportWidth: number,
|
|
204
|
+
viewportHeight: number,
|
|
205
|
+
entity: EntityHandle,
|
|
206
|
+
): VertexHit[] {
|
|
207
|
+
// ── camera validation + view/projection + screen->world ray (pick-core skeleton) ──
|
|
208
|
+
// Throws PickError('camera-component-missing') when cameraEntity has no Camera;
|
|
209
|
+
// returns undefined when the camera has no resolvable Transform.world (D-9 preamble miss).
|
|
210
|
+
const screenRay = computeScreenRay(
|
|
211
|
+
world,
|
|
212
|
+
cameraEntity,
|
|
213
|
+
screenX,
|
|
214
|
+
screenY,
|
|
215
|
+
viewportWidth,
|
|
216
|
+
viewportHeight,
|
|
217
|
+
);
|
|
218
|
+
if (screenRay === undefined) {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
const { ray: r, view, proj } = screenRay;
|
|
222
|
+
|
|
223
|
+
// ── viewProj = proj * view (precompute for worldToScreen calls) ──
|
|
224
|
+
const viewProj = mat4.create();
|
|
225
|
+
mat4.multiply(viewProj, proj as unknown as mat4.Mat4Like, view as unknown as mat4.Mat4Like);
|
|
226
|
+
|
|
227
|
+
const rOx = r[0] as number;
|
|
228
|
+
const rOy = r[1] as number;
|
|
229
|
+
const rOz = r[2] as number;
|
|
230
|
+
const rDx = r[3] as number;
|
|
231
|
+
const rDy = r[4] as number;
|
|
232
|
+
const rDz = r[5] as number;
|
|
233
|
+
|
|
234
|
+
// ── resolve entity's mesh asset ──
|
|
235
|
+
const mfRes = world.get(entity, MeshFilter);
|
|
236
|
+
if (!mfRes.ok) {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
const meshRes = resolvePickingAsset<MeshAsset>(
|
|
240
|
+
world,
|
|
241
|
+
toShared<'MeshAsset'>(mfRes.value.assetHandle as unknown as number),
|
|
242
|
+
);
|
|
243
|
+
if (!meshRes.ok) {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
const mesh = meshRes.value;
|
|
247
|
+
|
|
248
|
+
// ── position attribute narrow (D-4: three-branch, mirrors computeAABB) ──
|
|
249
|
+
const positions = narrowPosition(mesh.attributes.position);
|
|
250
|
+
if (positions === undefined || positions.length < 3) {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── entity world transform (D-9: requires propagateTransforms preamble) ──
|
|
255
|
+
const entityWorld = readWorldMatrix(world, entity);
|
|
256
|
+
if (entityWorld === undefined) {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── deformed flag (AC-08: isSkinned, SSOT at asset-registry.ts:1417-1418) ──
|
|
261
|
+
const attrs = mesh.attributes;
|
|
262
|
+
const deformed: boolean =
|
|
263
|
+
attrs !== undefined && attrs.skinIndex !== undefined && attrs.skinWeight !== undefined;
|
|
264
|
+
|
|
265
|
+
// ── AABB coarse cull (if aabb present) ──
|
|
266
|
+
// AC-07: if aabb is undefined, DO NOT continue — fallthrough to walk-all-vertices.
|
|
267
|
+
// This is the key behavioral difference from pick.ts:184.
|
|
268
|
+
const entityWMLike = entityWorld as unknown as mat4.Mat4Like;
|
|
269
|
+
|
|
270
|
+
if (mesh.aabb !== undefined && !rayHitsWorldAabb(r, mesh.aabb, entityWMLike)) {
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
// AC-07: aabb===undefined → fall through to walk-all-vertices (builtin no-AABB fallback).
|
|
274
|
+
|
|
275
|
+
// ── iterate submeshes + triangles ──
|
|
276
|
+
const candidates: VertexHit[] = [];
|
|
277
|
+
const seen = new Set<number>(); // (entity, vertexIndex) dedup (review I-1)
|
|
278
|
+
const indices = mesh.indices;
|
|
279
|
+
const submeshes = mesh.submeshes;
|
|
280
|
+
const maxVertexIndex = Math.floor(positions.length / 3) - 1;
|
|
281
|
+
|
|
282
|
+
// Shared vertex-emit closure: given 3 vertex indices for a hit triangle,
|
|
283
|
+
// compute worldPos + screenDist + worldDist and push candidates.
|
|
284
|
+
// Extracted from the duplicated ~45-line indexed/non-indexed body (review I-2).
|
|
285
|
+
const emitTriangleVertices = (i0: number, i1: number, i2: number): void => {
|
|
286
|
+
const ax = positions[i0 * 3 + 0] as number;
|
|
287
|
+
const ay = positions[i0 * 3 + 1] as number;
|
|
288
|
+
const az = positions[i0 * 3 + 2] as number;
|
|
289
|
+
const bx = positions[i1 * 3 + 0] as number;
|
|
290
|
+
const by = positions[i1 * 3 + 1] as number;
|
|
291
|
+
const bz = positions[i1 * 3 + 2] as number;
|
|
292
|
+
const cx = positions[i2 * 3 + 0] as number;
|
|
293
|
+
const cy = positions[i2 * 3 + 1] as number;
|
|
294
|
+
const cz = positions[i2 * 3 + 2] as number;
|
|
295
|
+
|
|
296
|
+
const triResult = ray.rayTriangleIntersects(
|
|
297
|
+
r,
|
|
298
|
+
[ax, ay, az] as unknown as Vec3Like,
|
|
299
|
+
[bx, by, bz] as unknown as Vec3Like,
|
|
300
|
+
[cx, cy, cz] as unknown as Vec3Like,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
if (!triResult.hit) return;
|
|
304
|
+
|
|
305
|
+
for (const [vi, lx, ly, lz] of [
|
|
306
|
+
[i0, ax, ay, az],
|
|
307
|
+
[i1, bx, by, bz],
|
|
308
|
+
[i2, cx, cy, cz],
|
|
309
|
+
] as [number, number, number, number][]) {
|
|
310
|
+
if (Number.isNaN(lx) || Number.isNaN(ly) || Number.isNaN(lz)) continue;
|
|
311
|
+
if (!Number.isFinite(lx) || !Number.isFinite(ly) || !Number.isFinite(lz)) continue;
|
|
312
|
+
|
|
313
|
+
const worldVec = vec3.create();
|
|
314
|
+
mat4.transformPoint(worldVec, entityWMLike, [lx, ly, lz] as unknown as Vec3Like);
|
|
315
|
+
const wx = worldVec[0] as number;
|
|
316
|
+
const wy = worldVec[1] as number;
|
|
317
|
+
const wz = worldVec[2] as number;
|
|
318
|
+
|
|
319
|
+
const screenRes = ray.worldToScreen(
|
|
320
|
+
_scratchVec2,
|
|
321
|
+
[wx, wy, wz] as unknown as Vec3Like,
|
|
322
|
+
viewProj as unknown as import('@forgeax/engine-math').Mat4Like,
|
|
323
|
+
viewportWidth,
|
|
324
|
+
viewportHeight,
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
if (screenRes.behind) continue;
|
|
328
|
+
|
|
329
|
+
// Dedup: same vertex hit by multiple triangles — keep only first
|
|
330
|
+
// (same vertexIndex = same world-space position, screenDist/worldDist identical).
|
|
331
|
+
if (seen.has(vi)) continue;
|
|
332
|
+
seen.add(vi);
|
|
333
|
+
|
|
334
|
+
const sx = _scratchVec2[0] as number;
|
|
335
|
+
const sy = _scratchVec2[1] as number;
|
|
336
|
+
const sdx = sx - screenX;
|
|
337
|
+
const sdy = sy - screenY;
|
|
338
|
+
const screenDist = Math.sqrt(sdx * sdx + sdy * sdy);
|
|
339
|
+
const worldDist = pointToRayDist(wx, wy, wz, rOx, rOy, rOz, rDx, rDy, rDz);
|
|
340
|
+
|
|
341
|
+
candidates.push({
|
|
342
|
+
entity,
|
|
343
|
+
vertexIndex: vi,
|
|
344
|
+
worldPos: [wx, wy, wz] as unknown as Vec3Like,
|
|
345
|
+
screenDist,
|
|
346
|
+
worldDist,
|
|
347
|
+
deformed,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
for (const submesh of submeshes) {
|
|
353
|
+
// D-5: only triangle-list participates
|
|
354
|
+
if (submesh.topology !== 'triangle-list') continue;
|
|
355
|
+
|
|
356
|
+
const idxOffset = submesh.indexOffset;
|
|
357
|
+
const idxCount = submesh.indexCount;
|
|
358
|
+
|
|
359
|
+
if (indices !== undefined && indices.length > 0 && idxCount > 0) {
|
|
360
|
+
// indexed draw
|
|
361
|
+
const triCount = Math.floor(idxCount / 3);
|
|
362
|
+
for (let ti = 0; ti < triCount; ti++) {
|
|
363
|
+
const i0 = indices[idxOffset + ti * 3 + 0] as number;
|
|
364
|
+
const i1 = indices[idxOffset + ti * 3 + 1] as number;
|
|
365
|
+
const i2 = indices[idxOffset + ti * 3 + 2] as number;
|
|
366
|
+
|
|
367
|
+
if (i0 > maxVertexIndex || i1 > maxVertexIndex || i2 > maxVertexIndex) continue;
|
|
368
|
+
emitTriangleVertices(i0, i1, i2);
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
// AC-09: no index buffer → non-indexed triangle sequence
|
|
372
|
+
const submeshVertexCount = submesh.vertexCount;
|
|
373
|
+
const triCount = Math.floor(submeshVertexCount / 3);
|
|
374
|
+
|
|
375
|
+
for (let ti = 0; ti < triCount; ti++) {
|
|
376
|
+
const i0 = ti * 3;
|
|
377
|
+
const i1 = ti * 3 + 1;
|
|
378
|
+
const i2 = ti * 3 + 2;
|
|
379
|
+
|
|
380
|
+
if (i0 > maxVertexIndex || i1 > maxVertexIndex || i2 > maxVertexIndex) continue;
|
|
381
|
+
emitTriangleVertices(i0, i1, i2);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return candidates;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── overload signatures: pickVertexOnEntity (D-2: three-state static dispatch) ──
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Query the nearest vertex on a single entity.
|
|
393
|
+
*
|
|
394
|
+
* Without options: returns `VertexHit | undefined` (nearest hit, or `undefined` on miss).
|
|
395
|
+
*
|
|
396
|
+
* @param world The ECS world (propagateTransforms must have been called this frame).
|
|
397
|
+
* @param cameraEntity Entity carrying the Camera component (and Transform).
|
|
398
|
+
* @param screenX Horizontal pixel coordinate (viewport top-left, y-down).
|
|
399
|
+
* @param screenY Vertical pixel coordinate.
|
|
400
|
+
* @param viewportWidth Viewport width in pixels.
|
|
401
|
+
* @param viewportHeight Viewport height in pixels.
|
|
402
|
+
* @param entity The mesh entity to query (must carry MeshFilter + MeshRenderer).
|
|
403
|
+
* @returns The nearest `VertexHit`, or `undefined` when nothing is hit.
|
|
404
|
+
* @throws {PickError} `code: 'camera-component-missing'` when cameraEntity has no Camera.
|
|
405
|
+
*/
|
|
406
|
+
export function pickVertexOnEntity(
|
|
407
|
+
world: World,
|
|
408
|
+
cameraEntity: EntityHandle,
|
|
409
|
+
screenX: number,
|
|
410
|
+
screenY: number,
|
|
411
|
+
viewportWidth: number,
|
|
412
|
+
viewportHeight: number,
|
|
413
|
+
entity: EntityHandle,
|
|
414
|
+
): VertexHit | undefined;
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Query up to `limit` nearest vertices on a single entity.
|
|
418
|
+
*
|
|
419
|
+
* With `{ limit }`: returns `VertexHit[]` sorted by `screenDist` ascending.
|
|
420
|
+
*
|
|
421
|
+
* @param options.limit Maximum number of candidates to return (returns all available
|
|
422
|
+
* vertices when limit exceeds the hit count).
|
|
423
|
+
* @returns Sorted array of `VertexHit` (empty on miss).
|
|
424
|
+
*/
|
|
425
|
+
export function pickVertexOnEntity(
|
|
426
|
+
world: World,
|
|
427
|
+
cameraEntity: EntityHandle,
|
|
428
|
+
screenX: number,
|
|
429
|
+
screenY: number,
|
|
430
|
+
viewportWidth: number,
|
|
431
|
+
viewportHeight: number,
|
|
432
|
+
entity: EntityHandle,
|
|
433
|
+
options: { limit: number },
|
|
434
|
+
): VertexHit[];
|
|
435
|
+
|
|
436
|
+
export function pickVertexOnEntity(
|
|
437
|
+
world: World,
|
|
438
|
+
cameraEntity: EntityHandle,
|
|
439
|
+
screenX: number,
|
|
440
|
+
screenY: number,
|
|
441
|
+
viewportWidth: number,
|
|
442
|
+
viewportHeight: number,
|
|
443
|
+
entity: EntityHandle,
|
|
444
|
+
options?: { limit: number },
|
|
445
|
+
): VertexHit | VertexHit[] | undefined {
|
|
446
|
+
const candidates = collectVertexHits(
|
|
447
|
+
world,
|
|
448
|
+
cameraEntity,
|
|
449
|
+
screenX,
|
|
450
|
+
screenY,
|
|
451
|
+
viewportWidth,
|
|
452
|
+
viewportHeight,
|
|
453
|
+
entity,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
// ── sort by screenDist ascending ──
|
|
457
|
+
candidates.sort((a, b) => a.screenDist - b.screenDist);
|
|
458
|
+
|
|
459
|
+
// ── apply limit / return shape ──
|
|
460
|
+
const limit = options?.limit;
|
|
461
|
+
if (limit !== undefined) {
|
|
462
|
+
return candidates.slice(0, limit);
|
|
463
|
+
}
|
|
464
|
+
return candidates[0];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── overload signatures: pickVertex (full-scene, D-2: three-state static dispatch) ──
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Query the nearest vertex across all pickable mesh entities in the world.
|
|
471
|
+
*
|
|
472
|
+
* Without options: returns `VertexHit | undefined` (globally nearest, or `undefined` on miss).
|
|
473
|
+
*
|
|
474
|
+
* Walks all renderable archetypes, does an AABB coarse cull (R-2), then calls
|
|
475
|
+
* `pickVertexOnEntity` on each ray-intersecting entity. Builtin meshes without AABB
|
|
476
|
+
* fall through to walk-all-vertices (AC-07).
|
|
477
|
+
*
|
|
478
|
+
* @param world The ECS world (propagateTransforms must have been called this frame).
|
|
479
|
+
* @param cameraEntity Entity carrying the Camera component (and Transform).
|
|
480
|
+
* @param screenX Horizontal pixel coordinate (viewport top-left, y-down).
|
|
481
|
+
* @param screenY Vertical pixel coordinate.
|
|
482
|
+
* @param viewportWidth Viewport width in pixels.
|
|
483
|
+
* @param viewportHeight Viewport height in pixels.
|
|
484
|
+
* @returns The globally nearest `VertexHit`, or `undefined` when nothing is hit.
|
|
485
|
+
* @throws {PickError} `code: 'camera-component-missing'` when cameraEntity has no Camera.
|
|
486
|
+
*/
|
|
487
|
+
export function pickVertex(
|
|
488
|
+
world: World,
|
|
489
|
+
cameraEntity: EntityHandle,
|
|
490
|
+
screenX: number,
|
|
491
|
+
screenY: number,
|
|
492
|
+
viewportWidth: number,
|
|
493
|
+
viewportHeight: number,
|
|
494
|
+
): VertexHit | undefined;
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Query up to `limit` nearest vertices across all pickable mesh entities.
|
|
498
|
+
*
|
|
499
|
+
* With `{ limit }`: returns `VertexHit[]` globally sorted by `screenDist` ascending.
|
|
500
|
+
*
|
|
501
|
+
* @param options.limit Maximum number of candidates to return.
|
|
502
|
+
* @returns Sorted array of `VertexHit` (empty on miss).
|
|
503
|
+
*/
|
|
504
|
+
export function pickVertex(
|
|
505
|
+
world: World,
|
|
506
|
+
cameraEntity: EntityHandle,
|
|
507
|
+
screenX: number,
|
|
508
|
+
screenY: number,
|
|
509
|
+
viewportWidth: number,
|
|
510
|
+
viewportHeight: number,
|
|
511
|
+
options: { limit: number },
|
|
512
|
+
): VertexHit[];
|
|
513
|
+
|
|
514
|
+
export function pickVertex(
|
|
515
|
+
world: World,
|
|
516
|
+
cameraEntity: EntityHandle,
|
|
517
|
+
screenX: number,
|
|
518
|
+
screenY: number,
|
|
519
|
+
viewportWidth: number,
|
|
520
|
+
viewportHeight: number,
|
|
521
|
+
options?: { limit: number },
|
|
522
|
+
): VertexHit | VertexHit[] | undefined {
|
|
523
|
+
// ── camera validation + view/projection + screen->world ray (pick-core skeleton) ──
|
|
524
|
+
// Throws PickError('camera-component-missing') when cameraEntity has no Camera;
|
|
525
|
+
// returns undefined when the camera has no resolvable Transform.world (degenerate miss).
|
|
526
|
+
const screenRay = computeScreenRay(
|
|
527
|
+
world,
|
|
528
|
+
cameraEntity,
|
|
529
|
+
screenX,
|
|
530
|
+
screenY,
|
|
531
|
+
viewportWidth,
|
|
532
|
+
viewportHeight,
|
|
533
|
+
);
|
|
534
|
+
if (screenRay === undefined) {
|
|
535
|
+
if (options) return [];
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
const r = screenRay.ray;
|
|
539
|
+
|
|
540
|
+
// ── walk renderable archetypes (Transform + MeshFilter + MeshRenderer) ──
|
|
541
|
+
// Reuse pick.ts archetype walk skeleton (research Finding 1).
|
|
542
|
+
const query = world.query({ read: [Transform, MeshFilter, MeshRenderer] }).unwrap();
|
|
543
|
+
|
|
544
|
+
const allCandidates: VertexHit[] = [];
|
|
545
|
+
|
|
546
|
+
for (const row of query) {
|
|
547
|
+
const assetHandleRaw = Math.round(row.get(MeshFilter).assetHandle as number);
|
|
548
|
+
if (assetHandleRaw === 0) continue;
|
|
549
|
+
const meshRes = resolvePickingAsset<MeshAsset>(world, toShared<'MeshAsset'>(assetHandleRaw));
|
|
550
|
+
if (!meshRes.ok) continue;
|
|
551
|
+
|
|
552
|
+
// read entity (mirrors pick.ts:190)
|
|
553
|
+
const entity = row.entity;
|
|
554
|
+
|
|
555
|
+
// read entity world matrix
|
|
556
|
+
const entityWorld = readWorldMatrix(world, entity);
|
|
557
|
+
if (entityWorld === undefined) continue;
|
|
558
|
+
|
|
559
|
+
// AABB coarse cull (R-2): if aabb present, test ray intersection.
|
|
560
|
+
// If aabb===undefined (builtin), fall through to collectVertexHits (AC-07).
|
|
561
|
+
const mesh = meshRes.value;
|
|
562
|
+
if (
|
|
563
|
+
mesh.aabb !== undefined &&
|
|
564
|
+
!rayHitsWorldAabb(r, mesh.aabb, entityWorld as unknown as mat4.Mat4Like)
|
|
565
|
+
) {
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Collect vertices for this entity
|
|
570
|
+
const entityHits = collectVertexHits(
|
|
571
|
+
world,
|
|
572
|
+
cameraEntity,
|
|
573
|
+
screenX,
|
|
574
|
+
screenY,
|
|
575
|
+
viewportWidth,
|
|
576
|
+
viewportHeight,
|
|
577
|
+
entity,
|
|
578
|
+
);
|
|
579
|
+
for (const h of entityHits) {
|
|
580
|
+
allCandidates.push(h);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ── global sort by screenDist ascending ──
|
|
585
|
+
allCandidates.sort((a, b) => a.screenDist - b.screenDist);
|
|
586
|
+
|
|
587
|
+
// ── apply limit / return shape ──
|
|
588
|
+
const limit = options?.limit;
|
|
589
|
+
if (limit !== undefined) {
|
|
590
|
+
return allCandidates.slice(0, limit);
|
|
591
|
+
}
|
|
592
|
+
return allCandidates[0];
|
|
593
|
+
}
|