@forgeax/engine-picking 0.1.31 → 0.1.32

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 CHANGED
@@ -15,11 +15,16 @@ never the reverse.
15
15
  through the camera, walks every renderable archetype, ray-AABB tests each
16
16
  pickable mesh's world-space bounding box, and returns the nearest
17
17
  `PickHit { entity, point, distance }` (or `undefined` on a miss). AABB
18
- granularity (Three.js `Raycaster`-aligned MVP; per-triangle precision is
19
- `pickVertex`).
18
+ granularity is intentional; use `pickTriangle` when exact surface and
19
+ occlusion ordering are required.
20
20
  - **`viewportToWorld(world, cameraEntity, screenX, screenY, viewportWidth, viewportHeight)`**
21
21
  — exposes the same camera unprojection as a world-space `Ray` for cursor
22
22
  placement, gizmos, and custom plane/triangle queries.
23
+ - **`pickTriangle`** — exact static CPU triangle query for a screen ray. It
24
+ transforms indexed or non-indexed triangle-list vertices into world space,
25
+ returns the nearest world point, distance, barycentric weights, entity,
26
+ triangle index, and optional Catalog GUID, or reports `unavailable` when
27
+ CPU geometry or the current skinned pose cannot be tested.
23
28
  - **`pickVertexOnEntity` / `pickVertex`** — per-triangle vertex-level queries
24
29
  (editor vertex-snapping workflow). Three-state static-dispatch overload:
25
30
  without options -> `VertexHit | undefined`; with `{ limit: N }` -> `VertexHit[]`
@@ -74,7 +79,7 @@ if (hit) {
74
79
 
75
80
  `PickHit = { entity: EntityHandle; point: Vec3Like; distance: number }`. No
76
81
  `face` / `uv` / `normal` — AABB picking has no triangle resolution, so those
77
- would be a lie (use `pickVertex` for per-triangle vertices). Both `perspective`
82
+ would be a lie (use `pickTriangle` or `pickVertex` for precise geometry). Both `perspective`
78
83
  and `orthographic` camera projections are supported. Reads the resolved
79
84
  `GlobalTransform.world` mat4 directly (feat-20260601 D-3), so the camera + candidates
80
85
  must have propagated transforms for the current frame.
@@ -117,6 +122,23 @@ Only `triangle-list` submeshes participate; skinned meshes report
117
122
  > `GlobalTransform.world` column-major mat4 directly; they never re-propagate. The
118
123
  > contract is identical across `pick` and `pickVertex*`.
119
124
 
125
+ ### Exact triangle (`pickTriangle`)
126
+
127
+ | Function | Signature | Return |
128
+ |:--|:--|:--|
129
+ | `pickTriangle` | `(world, cameraEntity, screenX, screenY, vpW, vpH, options?)` | `TrianglePickResult` |
130
+
131
+ `TrianglePickResult` is a closed three-state result: `hit` contains the
132
+ nearest `TriangleHit`; `miss` means every candidate was tested and no triangle
133
+ intersects the ray; `unavailable` names intersecting entities whose CPU
134
+ geometry, current skinned pose, or explicit instance transforms cannot be
135
+ tested. For an entity carrying `Instances`, pass its renderer-owned
136
+ `InstanceCollectionStore` as `options.instances`; a hit then includes the
137
+ zero-based `instanceIndex`. The picker uses the store's non-consuming `peek`
138
+ read, so inspection cannot consume a later render upload range. Pass
139
+ `AssetRegistry.guidOf` as `assetGuidOf` when provenance is needed. The query
140
+ does not mutate the World or own an asset registry.
141
+
120
142
  ### Tile-cell (`pickTile`)
121
143
 
122
144
  | Function | Signature | Return |
@@ -164,5 +186,6 @@ miss returns `undefined`).
164
186
  - `src/pick.ts` — `pick` + `PickHit`
165
187
  - `src/pick-vertex.ts` — `pickVertex` / `pickVertexOnEntity` + `VertexHit`
166
188
  - `src/pick-tile.ts` — `pickTile` + `PickTileHit` / `PickTileError`
189
+ - `src/pick-triangle.ts` — exact triangle/occlusion query + unavailable state
167
190
  - `src/pick-errors.ts` — `PickError` / `PickErrorCode` (error SSOT)
168
191
  - `src/pick-core.ts` — shared camera->ray skeleton (internal)
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=pick-triangle.unit.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pick-triangle.unit.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/pick-triangle.unit.test.ts"],"names":[],"mappings":""}
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export { pick } from './pick';
3
3
  export type { PickErrorCode } from './pick-errors';
4
4
  export { PickError } from './pick-errors';
5
5
  export { type PickTileError, type PickTileHit, pickTile } from './pick-tile';
6
+ export { pickTriangle, type TriangleHit, type TrianglePickOptions, type TrianglePickResult, type TrianglePickUnavailableReason, } from './pick-triangle';
6
7
  export type { VertexHit } from './pick-vertex';
7
8
  export { pickVertex, pickVertexOnEntity } from './pick-vertex';
8
9
  export { viewportToWorld } from './viewport-to-world';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,YAAY,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAG9B,YAAY,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE1C,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,WAAW,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE7E,YAAY,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAC/D,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,YAAY,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAG9B,YAAY,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE1C,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,WAAW,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC7E,OAAO,EACL,YAAY,EACZ,KAAK,WAAW,EAChB,KAAK,mBAAmB,EACxB,KAAK,kBAAkB,EACvB,KAAK,6BAA6B,GACnC,MAAM,iBAAiB,CAAC;AAEzB,YAAY,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAC/D,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC"}
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { resolveAssetHandle } from '@forgeax/engine-assets-runtime';
2
2
  import { vec2, box3, ray, vec3, mat4 } from '@forgeax/engine-math';
3
- import { MeshFilter, MeshRenderer, Camera, cameraProjectionFromF32 } from '@forgeax/engine-render';
3
+ import { MeshFilter, MeshRenderer, Instances, Camera, cameraProjectionFromF32 } from '@forgeax/engine-render';
4
4
  import { Transform, GlobalTransform, ChildOf } from '@forgeax/engine-scene';
5
5
  import { toShared, err, ok } from '@forgeax/engine-types';
6
6
  import { Entity } from '@forgeax/engine-ecs';
@@ -171,6 +171,238 @@ function pickTile(world, tilemapEntity, worldX, worldY) {
171
171
  }
172
172
  return ok(null);
173
173
  }
174
+ function narrowPosition(position) {
175
+ if (position instanceof Float32Array) return position;
176
+ if (position instanceof ArrayBuffer) return new Float32Array(position);
177
+ return void 0;
178
+ }
179
+ function worldAabbHit(screenRay, aabb, worldMatrix) {
180
+ if (aabb === void 0 || aabb[0] > aabb[3]) return true;
181
+ const corners = [
182
+ [aabb[0], aabb[1], aabb[2]],
183
+ [aabb[3], aabb[1], aabb[2]],
184
+ [aabb[0], aabb[4], aabb[2]],
185
+ [aabb[3], aabb[4], aabb[2]],
186
+ [aabb[0], aabb[1], aabb[5]],
187
+ [aabb[3], aabb[1], aabb[5]],
188
+ [aabb[0], aabb[4], aabb[5]],
189
+ [aabb[3], aabb[4], aabb[5]]
190
+ ];
191
+ const world = vec3.create();
192
+ let minX = Infinity;
193
+ let minY = Infinity;
194
+ let minZ = Infinity;
195
+ let maxX = -Infinity;
196
+ let maxY = -Infinity;
197
+ let maxZ = -Infinity;
198
+ for (const corner of corners) {
199
+ mat4.transformPoint(world, worldMatrix, corner);
200
+ minX = Math.min(minX, world[0]);
201
+ minY = Math.min(minY, world[1]);
202
+ minZ = Math.min(minZ, world[2]);
203
+ maxX = Math.max(maxX, world[0]);
204
+ maxY = Math.max(maxY, world[1]);
205
+ maxZ = Math.max(maxZ, world[2]);
206
+ }
207
+ return ray.rayAabbIntersects(screenRay, [minX, minY, minZ, maxX, maxY, maxZ]).hit;
208
+ }
209
+ function pointOnRay(screenRay, point) {
210
+ const dx = point[0] - screenRay[0];
211
+ const dy = point[1] - screenRay[1];
212
+ const dz = point[2] - screenRay[2];
213
+ return dx * screenRay[3] + dy * screenRay[4] + dz * screenRay[5];
214
+ }
215
+ function pickTriangle(world, cameraEntity, screenX, screenY, viewportWidth, viewportHeight, options = {}) {
216
+ const screen = computeScreenRay(
217
+ world,
218
+ cameraEntity,
219
+ screenX,
220
+ screenY,
221
+ viewportWidth,
222
+ viewportHeight
223
+ );
224
+ if (screen === void 0) return { status: "miss", precision: "triangle" };
225
+ const origin = screen.ray;
226
+ const query = world.query({
227
+ read: [Transform, GlobalTransform, MeshFilter, MeshRenderer],
228
+ optional: [Instances]
229
+ }).unwrap();
230
+ let best;
231
+ let unsupportedReason;
232
+ const unsupportedEntities = [];
233
+ for (const row of query) {
234
+ const rawHandle = Math.round(row.get(MeshFilter).assetHandle);
235
+ if (rawHandle === 0) continue;
236
+ const resolved = resolveAssetHandle(world, toShared(rawHandle));
237
+ if (!resolved.ok) continue;
238
+ const mesh = resolved.value;
239
+ const entity = row.entity;
240
+ const entityWorldMatrix = readWorldMatrix(world, entity);
241
+ if (entityWorldMatrix === void 0) continue;
242
+ const instancesData = row.get(Instances);
243
+ let instanceTransforms;
244
+ let instanceCount = 1;
245
+ if (instancesData !== void 0) {
246
+ if (options.instances === void 0) {
247
+ unsupportedReason ??= "instance-transforms-unavailable";
248
+ unsupportedEntities.push(entity);
249
+ continue;
250
+ }
251
+ const snapshot = options.instances.peek(instancesData.collectionId);
252
+ if (!snapshot.ok) {
253
+ unsupportedReason ??= "instance-transforms-unavailable";
254
+ unsupportedEntities.push(entity);
255
+ continue;
256
+ }
257
+ instanceTransforms = snapshot.value.transforms;
258
+ instanceCount = snapshot.value.count;
259
+ }
260
+ const positions = narrowPosition(mesh.attributes.position);
261
+ const skinned = mesh.attributes.skinIndex !== void 0 && mesh.attributes.skinWeight !== void 0;
262
+ if (skinned) {
263
+ let intersects = false;
264
+ for (let instanceIndex = 0; instanceIndex < instanceCount; instanceIndex += 1) {
265
+ const drawWorld = instanceTransforms === void 0 ? entityWorldMatrix : mat4.multiply(
266
+ mat4.create(),
267
+ entityWorldMatrix,
268
+ instanceTransforms.subarray(instanceIndex * 16, instanceIndex * 16 + 16)
269
+ );
270
+ if (worldAabbHit(origin, mesh.aabb, drawWorld)) {
271
+ intersects = true;
272
+ break;
273
+ }
274
+ }
275
+ if (!intersects) continue;
276
+ unsupportedReason ??= "skinned-pose-unavailable";
277
+ unsupportedEntities.push(entity);
278
+ continue;
279
+ }
280
+ if (positions === void 0 || positions.length < 3) {
281
+ let intersects = false;
282
+ for (let instanceIndex = 0; instanceIndex < instanceCount; instanceIndex += 1) {
283
+ const drawWorld = instanceTransforms === void 0 ? entityWorldMatrix : mat4.multiply(
284
+ mat4.create(),
285
+ entityWorldMatrix,
286
+ instanceTransforms.subarray(instanceIndex * 16, instanceIndex * 16 + 16)
287
+ );
288
+ if (worldAabbHit(origin, mesh.aabb, drawWorld)) {
289
+ intersects = true;
290
+ break;
291
+ }
292
+ }
293
+ if (!intersects) continue;
294
+ unsupportedReason ??= "cpu-geometry-unavailable";
295
+ unsupportedEntities.push(entity);
296
+ continue;
297
+ }
298
+ for (let instanceIndex = 0; instanceIndex < instanceCount; instanceIndex += 1) {
299
+ const drawWorld = instanceTransforms === void 0 ? entityWorldMatrix : mat4.multiply(
300
+ mat4.create(),
301
+ entityWorldMatrix,
302
+ instanceTransforms.subarray(instanceIndex * 16, instanceIndex * 16 + 16)
303
+ );
304
+ if (!worldAabbHit(origin, mesh.aabb, drawWorld)) continue;
305
+ const worldA = vec3.create();
306
+ const worldB = vec3.create();
307
+ const worldC = vec3.create();
308
+ const worldPoint = vec3.create();
309
+ let triangleIndex = 0;
310
+ const indices = mesh.indices;
311
+ const maxVertex = Math.floor(positions.length / 3) - 1;
312
+ const test = (i0, i1, i2, index) => {
313
+ if (i0 < 0 || i1 < 0 || i2 < 0 || i0 > maxVertex || i1 > maxVertex || i2 > maxVertex)
314
+ return;
315
+ const a = [
316
+ positions[i0 * 3],
317
+ positions[i0 * 3 + 1],
318
+ positions[i0 * 3 + 2]
319
+ ];
320
+ const b = [
321
+ positions[i1 * 3],
322
+ positions[i1 * 3 + 1],
323
+ positions[i1 * 3 + 2]
324
+ ];
325
+ const c = [
326
+ positions[i2 * 3],
327
+ positions[i2 * 3 + 1],
328
+ positions[i2 * 3 + 2]
329
+ ];
330
+ mat4.transformPoint(worldA, drawWorld, a);
331
+ mat4.transformPoint(worldB, drawWorld, b);
332
+ mat4.transformPoint(worldC, drawWorld, c);
333
+ const hit = ray.rayTriangleIntersects(origin, worldA, worldB, worldC);
334
+ if (!hit.hit) return;
335
+ worldPoint[0] = origin[0] + origin[3] * hit.t;
336
+ worldPoint[1] = origin[1] + origin[4] * hit.t;
337
+ worldPoint[2] = origin[2] + origin[5] * hit.t;
338
+ const distance = pointOnRay(origin, worldPoint);
339
+ if (!Number.isFinite(distance) || distance < 0 || best !== void 0 && distance >= best.distance)
340
+ return;
341
+ const assetGuid = options.assetGuidOf?.(mesh);
342
+ best = {
343
+ entity,
344
+ triangleIndex: index,
345
+ point: [worldPoint[0], worldPoint[1], worldPoint[2]],
346
+ distance,
347
+ barycentric: [1 - hit.u - hit.v, hit.u, hit.v],
348
+ ...assetGuid === void 0 ? {} : { assetGuid },
349
+ ...instanceTransforms === void 0 ? {} : { instanceIndex },
350
+ precision: "triangle"
351
+ };
352
+ };
353
+ for (const submesh of mesh.submeshes) {
354
+ if (submesh.topology !== "triangle-list" && submesh.topology !== "triangle-strip") continue;
355
+ const strip = submesh.topology === "triangle-strip";
356
+ if (indices !== void 0 && indices.length > 0 && submesh.indexCount > 0) {
357
+ const count = strip ? Math.max(0, submesh.indexCount - 2) : Math.floor(submesh.indexCount / 3);
358
+ for (let localTriangle = 0; localTriangle < count; localTriangle += 1) {
359
+ if (strip) {
360
+ const base = submesh.indexOffset + localTriangle;
361
+ const i0 = indices[base];
362
+ const i1 = indices[base + 1];
363
+ const i2 = indices[base + 2];
364
+ if ((localTriangle & 1) === 0) test(i0, i1, i2, triangleIndex);
365
+ else test(i2, i1, i0, triangleIndex);
366
+ } else {
367
+ const base = submesh.indexOffset + localTriangle * 3;
368
+ test(
369
+ indices[base],
370
+ indices[base + 1],
371
+ indices[base + 2],
372
+ triangleIndex
373
+ );
374
+ }
375
+ triangleIndex += 1;
376
+ }
377
+ } else {
378
+ const count = strip ? Math.max(0, submesh.vertexCount - 2) : Math.floor(submesh.vertexCount / 3);
379
+ for (let localTriangle = 0; localTriangle < count; localTriangle += 1) {
380
+ if (strip) {
381
+ const i0 = localTriangle;
382
+ const i1 = localTriangle + 1;
383
+ const i2 = localTriangle + 2;
384
+ if ((localTriangle & 1) === 0) test(i0, i1, i2, triangleIndex);
385
+ else test(i2, i1, i0, triangleIndex);
386
+ } else {
387
+ const base = localTriangle * 3;
388
+ test(base, base + 1, base + 2, triangleIndex);
389
+ }
390
+ triangleIndex += 1;
391
+ }
392
+ }
393
+ }
394
+ }
395
+ }
396
+ if (unsupportedReason !== void 0) {
397
+ return {
398
+ status: "unavailable",
399
+ precision: "unavailable",
400
+ reason: unsupportedReason,
401
+ entities: unsupportedEntities
402
+ };
403
+ }
404
+ return best === void 0 ? { status: "miss", precision: "triangle" } : { status: "hit", hit: best };
405
+ }
174
406
  var _scratchVec2 = vec2.create();
175
407
  function pointToRayDist(px, py, pz, ox, oy, oz, dx, dy, dz) {
176
408
  const ex = px - ox;
@@ -221,7 +453,7 @@ function rayHitsWorldAabb(r, localAabb, worldMat) {
221
453
  );
222
454
  return aabbResult.hit;
223
455
  }
224
- function narrowPosition(positionAttr) {
456
+ function narrowPosition2(positionAttr) {
225
457
  if (positionAttr instanceof Float32Array) {
226
458
  return positionAttr;
227
459
  }
@@ -263,7 +495,7 @@ function collectVertexHits(world, cameraEntity, screenX, screenY, viewportWidth,
263
495
  return [];
264
496
  }
265
497
  const mesh = meshRes.value;
266
- const positions = narrowPosition(mesh.attributes.position);
498
+ const positions = narrowPosition2(mesh.attributes.position);
267
499
  if (positions === void 0 || positions.length < 3) {
268
500
  return [];
269
501
  }
@@ -292,12 +524,13 @@ function collectVertexHits(world, cameraEntity, screenX, screenY, viewportWidth,
292
524
  const cx = positions[i2 * 3 + 0];
293
525
  const cy = positions[i2 * 3 + 1];
294
526
  const cz = positions[i2 * 3 + 2];
295
- const triResult = ray.rayTriangleIntersects(
296
- r,
297
- [ax, ay, az],
298
- [bx, by, bz],
299
- [cx, cy, cz]
300
- );
527
+ const worldA = vec3.create();
528
+ const worldB = vec3.create();
529
+ const worldC = vec3.create();
530
+ mat4.transformPoint(worldA, entityWMLike, [ax, ay, az]);
531
+ mat4.transformPoint(worldB, entityWMLike, [bx, by, bz]);
532
+ mat4.transformPoint(worldC, entityWMLike, [cx, cy, cz]);
533
+ const triResult = ray.rayTriangleIntersects(r, worldA, worldB, worldC);
301
534
  if (!triResult.hit) return;
302
535
  for (const [vi, lx, ly, lz] of [
303
536
  [i0, ax, ay, az],
@@ -306,8 +539,7 @@ function collectVertexHits(world, cameraEntity, screenX, screenY, viewportWidth,
306
539
  ]) {
307
540
  if (Number.isNaN(lx) || Number.isNaN(ly) || Number.isNaN(lz)) continue;
308
541
  if (!Number.isFinite(lx) || !Number.isFinite(ly) || !Number.isFinite(lz)) continue;
309
- const worldVec = vec3.create();
310
- mat4.transformPoint(worldVec, entityWMLike, [lx, ly, lz]);
542
+ const worldVec = vi === i0 ? worldA : vi === i1 ? worldB : worldC;
311
543
  const wx = worldVec[0];
312
544
  const wy = worldVec[1];
313
545
  const wz = worldVec[2];
@@ -435,6 +667,6 @@ function viewportToWorld(world, cameraEntity, screenX, screenY, viewportWidth, v
435
667
  return computeScreenRay(world, cameraEntity, screenX, screenY, viewportWidth, viewportHeight)?.ray;
436
668
  }
437
669
 
438
- export { PickError, pick, pickTile, pickVertex, pickVertexOnEntity, viewportToWorld };
670
+ export { PickError, pick, pickTile, pickTriangle, pickVertex, pickVertexOnEntity, viewportToWorld };
439
671
  //# sourceMappingURL=index.mjs.map
440
672
  //# sourceMappingURL=index.mjs.map