@adobe/data-gpu 0.9.87 → 0.9.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/core/core-plugin.d.ts +1 -1
  2. package/dist/graphics/animation/animation-plugin.d.ts +2 -2
  3. package/dist/graphics/camera/camera-plugin.d.ts +1 -1
  4. package/dist/graphics/camera/orbit/orbit-data-plugin.d.ts +1 -1
  5. package/dist/graphics/camera/orbit/orbit-plugin.d.ts +1 -1
  6. package/dist/graphics/camera/orbit/orbit-system-plugin.d.ts +1 -1
  7. package/dist/graphics/graphics-plugin.d.ts +1 -1
  8. package/dist/graphics/picking/picking-plugin.d.ts +798 -861
  9. package/dist/graphics/rendering/bone-collider-plugin.d.ts +2647 -275
  10. package/dist/graphics/rendering/bone-collider-plugin.js +4 -4
  11. package/dist/graphics/rendering/bone-collider-plugin.js.map +1 -1
  12. package/dist/graphics/rendering/display-transform-plugin.d.ts +2 -2
  13. package/dist/graphics/rendering/display-transform-plugin.js +2 -2
  14. package/dist/graphics/rendering/display-transform-plugin.js.map +1 -1
  15. package/dist/graphics/rendering/ibl-render/ibl-render-plugin.d.ts +159 -177
  16. package/dist/graphics/rendering/interpolation-plugin.d.ts +2447 -56
  17. package/dist/graphics/rendering/jolt-ragdoll-plugin.d.ts +2634 -262
  18. package/dist/graphics/rendering/material-palette-gpu/material-palette-gpu-plugin.d.ts +8 -8
  19. package/dist/graphics/rendering/material-palette-gpu/material-palette-gpu-plugin.js +1 -1
  20. package/dist/graphics/rendering/material-palette-gpu/material-palette-gpu-plugin.js.map +1 -1
  21. package/dist/graphics/rendering/model-collider-plugin.d.ts +2567 -192
  22. package/dist/graphics/rendering/pbr-core-plugin.d.ts +5 -5
  23. package/dist/graphics/rendering/pbr-core-plugin.js +5 -5
  24. package/dist/graphics/rendering/pbr-core-plugin.js.map +1 -1
  25. package/dist/graphics/rendering/pbr-render/pbr-factor-render-plugin.d.ts +172 -190
  26. package/dist/graphics/rendering/pbr-render/physics-bridge-plugin.d.ts +2398 -5
  27. package/dist/graphics/rendering/skinning/skinning-plugin.d.ts +189 -210
  28. package/dist/graphics/rendering/skinning/skinning-plugin.js +1 -1
  29. package/dist/graphics/rendering/skinning/skinning-plugin.js.map +1 -1
  30. package/dist/graphics/scene/model/mesh-plugin.d.ts +20 -50
  31. package/dist/graphics/scene/model/model-loader-plugin.d.ts +156 -174
  32. package/dist/graphics/scene/model/shape/shape-geometry-plugin.d.ts +73 -76
  33. package/dist/graphics/scene/model/world-bounds-plugin.d.ts +156 -174
  34. package/dist/graphics/scene/node/node-data-plugin.d.ts +2 -5
  35. package/dist/graphics/scene/node/node-data-plugin.js +2 -2
  36. package/dist/graphics/scene/node/node-data-plugin.js.map +1 -1
  37. package/dist/graphics/scene/node/node-plugin.d.ts +2 -5
  38. package/dist/graphics/scene/node/transform-plugin.d.ts +2 -5
  39. package/dist/graphics/scene/scene-plugin.d.ts +16 -31
  40. package/dist/graphics/scene/scene-uniforms/scene-uniforms-plugin.d.ts +1 -1
  41. package/dist/physics/physics-clock-plugin.d.ts +1 -1
  42. package/dist/physics/physics-data-plugin.d.ts +2400 -4
  43. package/dist/physics/physics-data-plugin.js +28 -2
  44. package/dist/physics/physics-data-plugin.js.map +1 -1
  45. package/dist/physics/physics-hit.d.ts +18 -0
  46. package/dist/physics/physics-hit.js +3 -0
  47. package/dist/physics/physics-hit.js.map +1 -0
  48. package/dist/physics/physics-query.d.ts +43 -0
  49. package/dist/physics/physics-query.js +3 -0
  50. package/dist/physics/physics-query.js.map +1 -0
  51. package/dist/physics/public.d.ts +2 -0
  52. package/dist/physics/public.js +2 -0
  53. package/dist/physics/public.js.map +1 -1
  54. package/dist/physics/solvers/jolt-solver-plugin.d.ts +2399 -9
  55. package/dist/physics/solvers/jolt-solver-plugin.js +151 -2
  56. package/dist/physics/solvers/jolt-solver-plugin.js.map +1 -1
  57. package/dist/physics/solvers/mutable-hit.d.ts +14 -0
  58. package/dist/physics/solvers/mutable-hit.js +3 -0
  59. package/dist/physics/solvers/mutable-hit.js.map +1 -0
  60. package/dist/physics/solvers/orient-normal-against-ray.d.ts +11 -0
  61. package/dist/physics/solvers/orient-normal-against-ray.js +25 -0
  62. package/dist/physics/solvers/orient-normal-against-ray.js.map +1 -0
  63. package/dist/physics/solvers/orient-normal-against-ray.test.d.ts +1 -0
  64. package/dist/physics/solvers/orient-normal-against-ray.test.js +27 -0
  65. package/dist/physics/solvers/orient-normal-against-ray.test.js.map +1 -0
  66. package/dist/physics/solvers/pick-ray-conformance.test.d.ts +1 -0
  67. package/dist/physics/solvers/pick-ray-conformance.test.js +159 -0
  68. package/dist/physics/solvers/pick-ray-conformance.test.js.map +1 -0
  69. package/dist/physics/solvers/rapier-solver-plugin.d.ts +2399 -9
  70. package/dist/physics/solvers/rapier-solver-plugin.js +112 -3
  71. package/dist/physics/solvers/rapier-solver-plugin.js.map +1 -1
  72. package/package.json +2 -2
  73. package/src/graphics/rendering/bone-collider-plugin.ts +4 -4
  74. package/src/graphics/rendering/display-transform-plugin.ts +2 -2
  75. package/src/graphics/rendering/material-palette-gpu/material-palette-gpu-plugin.ts +1 -1
  76. package/src/graphics/rendering/pbr-core-plugin.ts +5 -5
  77. package/src/graphics/rendering/skinning/skinning-plugin.ts +1 -1
  78. package/src/graphics/scene/node/node-data-plugin.ts +2 -2
  79. package/src/physics/README.md +15 -2
  80. package/src/physics/physics-data-plugin.ts +31 -3
  81. package/src/physics/physics-hit.ts +21 -0
  82. package/src/physics/physics-query.ts +42 -0
  83. package/src/physics/public.ts +2 -0
  84. package/src/physics/solvers/jolt-solver-plugin.ts +121 -2
  85. package/src/physics/solvers/mutable-hit.ts +18 -0
  86. package/src/physics/solvers/orient-normal-against-ray.test.ts +31 -0
  87. package/src/physics/solvers/orient-normal-against-ray.ts +18 -0
  88. package/src/physics/solvers/pick-ray-conformance.test.ts +200 -0
  89. package/src/physics/solvers/rapier-solver-plugin.ts +94 -3
  90. package/tsconfig.tsbuildinfo +1 -1
@@ -2,6 +2,7 @@
2
2
 
3
3
  import initJolt from "jolt-physics";
4
4
  import { Database, type Entity } from "@adobe/data/ecs";
5
+ import type { Line3, Vec3 } from "@adobe/data/math";
5
6
  import { isVoxelShapePhysicsPending } from "../is-voxel-shape-physics-pending.js";
6
7
  import { True } from "@adobe/data/schema";
7
8
  import { physicsClock } from "../physics-clock-plugin.js";
@@ -11,6 +12,10 @@ import { BodyType } from "../body/body-type/body-type.js";
11
12
  import { ColliderShape } from "../body/collider-shape/collider-shape.js";
12
13
  import type { ColliderMesh } from "../body/collider-mesh.js";
13
14
  import { massFromVolume } from "../body/collider-shape/mass-from-volume.js";
15
+ import type { PhysicsQuery } from "../physics-query.js";
16
+ import type { PhysicsHit } from "../physics-hit.js";
17
+ import { orientNormalAgainstRay } from "./orient-normal-against-ray.js";
18
+ import { newHit, type MutableHit } from "./mutable-hit.js";
14
19
 
15
20
  /**
16
21
  * A third rigid-body solver behind the same `physicsData` seam — Jolt Physics
@@ -61,6 +66,8 @@ type JQuat = InstanceType<JoltModule["Quat"]>;
61
66
  type JVec3 = InstanceType<JoltModule["Vec3"]>;
62
67
  type JShape = InstanceType<JoltModule["Shape"]>;
63
68
  type JPhysicsSystem = InstanceType<JoltModule["PhysicsSystem"]>;
69
+ type JRayCastResult = InstanceType<JoltModule["RayCastResult"]>;
70
+ type JShapeCastResult = InstanceType<JoltModule["ShapeCastResult"]>;
64
71
 
65
72
  interface MatProps { density: number; restitution: number; friction: number }
66
73
 
@@ -78,8 +85,8 @@ export interface JoltContext {
78
85
  export const joltSolver = Database.Plugin.create({
79
86
  extends: Database.Plugin.combine(physicsData, jointData, physicsClock),
80
87
  components: {
81
- _joltBody: { ...True.schema, nonPersistent: true }, // tag: this body has been mirrored into the Jolt world
82
- _joltJoint: { ...True.schema, nonPersistent: true }, // tag: this joint has been mirrored into the Jolt world
88
+ _joltBody: { ...True.schema, default: undefined, nonPersistent: true }, // tag: this body has been mirrored into the Jolt world
89
+ _joltJoint: { ...True.schema, default: undefined, nonPersistent: true }, // tag: this joint has been mirrored into the Jolt world
83
90
  },
84
91
  resources: {
85
92
  _joltContext: { default: null as JoltContext | null, nonPersistent: true },
@@ -210,6 +217,7 @@ export const joltSolver = Database.Plugin.create({
210
217
  }
211
218
  }
212
219
  const body = bi.CreateBody(settings);
220
+ body.SetUserData(id); // reverse lookup for ray-pick: hit body id → entity
213
221
  bi.AddBody(body.GetID(), motion === "static" ? jolt.EActivation_DontActivate : jolt.EActivation_Activate);
214
222
  if (motion === "dynamic" && (vx || vy || vz || wx || wy || wz)) {
215
223
  const lv = new jolt.Vec3(vx, vy, vz), av = new jolt.Vec3(wx, wy, wz);
@@ -222,6 +230,116 @@ export const joltSolver = Database.Plugin.create({
222
230
  if (half) jolt.destroy(half);
223
231
  };
224
232
 
233
+ // Solver-agnostic ray-pick over the live Jolt world. As in the Rapier
234
+ // plugin, the segment vector (b − a) is the cast direction, so the hit
235
+ // fraction is the parametric α ∈ [0,1] along the segment directly
236
+ // (matching PhysicsHit). Accept-all filters + reusable settings are
237
+ // created once here; per-call WASM temporaries are freed after each pick.
238
+ const makeQuery = (jolt: JoltModule, ps: JPhysicsSystem, bi: JBodyInterface): PhysicsQuery => {
239
+ const narrow = ps.GetNarrowPhaseQuery();
240
+ const bpFilter = new jolt.BroadPhaseLayerFilter();
241
+ const objFilter = new jolt.ObjectLayerFilter();
242
+ const bodyFilter = new jolt.BodyFilter();
243
+ const shapeFilter = new jolt.ShapeFilter();
244
+ const raySettings = new jolt.RayCastSettings();
245
+ const shapeSettings = new jolt.ShapeCastSettings();
246
+ const baseOffset = new jolt.RVec3(0, 0, 0); // results in world space
247
+ const unitScale = new jolt.Vec3(1, 1, 1);
248
+
249
+ // Fill `out` from one ray result. Surface normal comes from the body
250
+ // we already hold (no body-lock round-trip). Reads every field into JS
251
+ // before any collector is destroyed. userData = entity id.
252
+ const fillRay = (out: MutableHit, hit: JRayCastResult, a: Vec3, dx: number, dy: number, dz: number): void => {
253
+ const alpha = hit.get_mFraction();
254
+ const entity = bi.GetUserData(hit.get_mBodyID()) as Entity;
255
+ out.entity = entity; out.distance = alpha;
256
+ out.point[0] = a[0] + dx * alpha; out.point[1] = a[1] + dy * alpha; out.point[2] = a[2] + dz * alpha;
257
+ out.normal[0] = 0; out.normal[1] = 0; out.normal[2] = 0;
258
+ const body = bodies.get(entity);
259
+ if (body) {
260
+ const p = new jolt.RVec3(out.point[0], out.point[1], out.point[2]);
261
+ const nv = body.GetWorldSpaceSurfaceNormal(hit.get_mSubShapeID2(), p);
262
+ out.normal[0] = nv.GetX(); out.normal[1] = nv.GetY(); out.normal[2] = nv.GetZ();
263
+ jolt.destroy(p);
264
+ }
265
+ orientNormalAgainstRay(out.normal, dx, dy, dz);
266
+ };
267
+ // Fill `out` from one swept-sphere result (contact point + penetration
268
+ // axis; both world-space with baseOffset = 0).
269
+ const fillShape = (out: MutableHit, hit: JShapeCastResult, dx: number, dy: number, dz: number): void => {
270
+ const cp = hit.get_mContactPointOn2();
271
+ out.entity = bi.GetUserData(hit.get_mBodyID2()) as Entity; out.distance = hit.get_mFraction();
272
+ out.point[0] = cp.GetX(); out.point[1] = cp.GetY(); out.point[2] = cp.GetZ();
273
+ const pax = hit.get_mPenetrationAxis();
274
+ out.normal[0] = pax.GetX(); out.normal[1] = pax.GetY(); out.normal[2] = pax.GetZ();
275
+ orientNormalAgainstRay(out.normal, dx, dy, dz);
276
+ };
277
+
278
+ const scratch = newHit(); // reused across castRayEach visits (zero-alloc)
279
+ return {
280
+ castRay(ray: Line3, options): PhysicsHit | null {
281
+ const a = ray.a, b = ray.b;
282
+ const dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
283
+ const out = newHit();
284
+ if ((options?.radius ?? 0) > 0) {
285
+ // sphere of `radius` swept along the segment
286
+ const shape = new jolt.SphereShape(options!.radius!);
287
+ const startPos = new jolt.RVec3(a[0], a[1], a[2]);
288
+ const comStart = jolt.RMat44.prototype.sTranslation(startPos);
289
+ const dir = new jolt.Vec3(dx, dy, dz);
290
+ const shapeCast = new jolt.RShapeCast(shape, unitScale, comStart, dir);
291
+ const collector = new jolt.CastShapeClosestHitCollisionCollector();
292
+ narrow.CastShape(shapeCast, shapeSettings, baseOffset, collector, bpFilter, objFilter, bodyFilter, shapeFilter);
293
+ const hadHit = collector.HadHit();
294
+ if (hadHit) fillShape(out, collector.mHit, dx, dy, dz);
295
+ jolt.destroy(shape); jolt.destroy(startPos); jolt.destroy(comStart); jolt.destroy(dir); jolt.destroy(shapeCast); jolt.destroy(collector);
296
+ return hadHit ? out : null;
297
+ }
298
+ const origin = new jolt.RVec3(a[0], a[1], a[2]);
299
+ const dir = new jolt.Vec3(dx, dy, dz);
300
+ const rc = new jolt.RRayCast(origin, dir);
301
+ const collector = new jolt.CastRayClosestHitCollisionCollector();
302
+ narrow.CastRay(rc, raySettings, collector, bpFilter, objFilter, bodyFilter, shapeFilter);
303
+ const hadHit = collector.HadHit();
304
+ if (hadHit) fillRay(out, collector.mHit, a, dx, dy, dz);
305
+ jolt.destroy(origin); jolt.destroy(dir); jolt.destroy(rc); jolt.destroy(collector);
306
+ return hadHit ? out : null;
307
+ },
308
+ castRayEach(ray: Line3, visit, options): void {
309
+ const a = ray.a, b = ray.b;
310
+ const dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
311
+ if ((options?.radius ?? 0) > 0) {
312
+ // Jolt has a native swept-sphere all-hits query.
313
+ const shape = new jolt.SphereShape(options!.radius!);
314
+ const startPos = new jolt.RVec3(a[0], a[1], a[2]);
315
+ const comStart = jolt.RMat44.prototype.sTranslation(startPos);
316
+ const dir = new jolt.Vec3(dx, dy, dz);
317
+ const shapeCast = new jolt.RShapeCast(shape, unitScale, comStart, dir);
318
+ const collector = new jolt.CastShapeAllHitCollisionCollector();
319
+ narrow.CastShape(shapeCast, shapeSettings, baseOffset, collector, bpFilter, objFilter, bodyFilter, shapeFilter);
320
+ if (collector.HadHit()) {
321
+ collector.Sort(); // near→far
322
+ const hits = collector.get_mHits();
323
+ for (let i = 0, len = hits.size(); i < len; i++) { fillShape(scratch, hits.at(i), dx, dy, dz); if (visit(scratch) === false) break; }
324
+ }
325
+ jolt.destroy(shape); jolt.destroy(startPos); jolt.destroy(comStart); jolt.destroy(dir); jolt.destroy(shapeCast); jolt.destroy(collector);
326
+ return;
327
+ }
328
+ const origin = new jolt.RVec3(a[0], a[1], a[2]);
329
+ const dir = new jolt.Vec3(dx, dy, dz);
330
+ const rc = new jolt.RRayCast(origin, dir);
331
+ const collector = new jolt.CastRayAllHitCollisionCollector();
332
+ narrow.CastRay(rc, raySettings, collector, bpFilter, objFilter, bodyFilter, shapeFilter);
333
+ if (collector.HadHit()) {
334
+ collector.Sort(); // near→far
335
+ const hits = collector.get_mHits();
336
+ for (let i = 0, len = hits.size(); i < len; i++) { fillRay(scratch, hits.at(i), a, dx, dy, dz); if (visit(scratch) === false) break; }
337
+ }
338
+ jolt.destroy(origin); jolt.destroy(dir); jolt.destroy(rc); jolt.destroy(collector);
339
+ },
340
+ };
341
+ };
342
+
225
343
  return () => {
226
344
  if (!J || !bodyInterface || !joltInterface) {
227
345
  if (!initStarted) {
@@ -229,6 +347,7 @@ export const joltSolver = Database.Plugin.create({
229
347
  initJolt().then((j: JoltModule) => {
230
348
  J = j; setup(j);
231
349
  db.store.resources._joltContext = { jolt: j, physicsSystem: physicsSystem!, bodyInterface: bodyInterface!, ragdollLayer: L_RAGDOLL };
350
+ db.store.resources.physicsQuery = makeQuery(j, physicsSystem!, bodyInterface!);
232
351
  });
233
352
  }
234
353
  return; // WASM not ready yet
@@ -0,0 +1,18 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+
3
+ import type { Entity } from "@adobe/data/ecs";
4
+
5
+ /**
6
+ * Internal mutable form of `PhysicsHit` used by the solver query
7
+ * implementations. `castRay` fills a fresh one (retainable return value);
8
+ * `castRayEach` fills and re-visits a single reused instance (zero-alloc).
9
+ * Assignable to the public read-only `PhysicsHit` at the seam.
10
+ */
11
+ export interface MutableHit {
12
+ entity: Entity;
13
+ distance: number;
14
+ point: [number, number, number];
15
+ normal: [number, number, number];
16
+ }
17
+
18
+ export const newHit = (): MutableHit => ({ entity: 0 as Entity, distance: 0, point: [0, 0, 0], normal: [0, 0, 0] });
@@ -0,0 +1,31 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+
3
+ import { describe, it, expect } from "vitest";
4
+ import { orientNormalAgainstRay } from "./orient-normal-against-ray.js";
5
+
6
+ describe("orientNormalAgainstRay", () => {
7
+ it("normalizes the normal to unit length", () => {
8
+ const n: [number, number, number] = [0, 3, 0];
9
+ orientNormalAgainstRay(n, 1, 0, 0); // ray perpendicular → no flip
10
+ expect(Math.hypot(...n)).toBeCloseTo(1, 6);
11
+ expect(n[1]).toBeCloseTo(1, 6);
12
+ });
13
+
14
+ it("flips a normal that points along the ray so it opposes it", () => {
15
+ const n: [number, number, number] = [0, -1, 0]; // points down, same as a downward ray
16
+ orientNormalAgainstRay(n, 0, -20, 0); // downward ray
17
+ expect(n[1]).toBeGreaterThan(0); // now points up, opposing the ray
18
+ });
19
+
20
+ it("leaves a normal that already opposes the ray unchanged in direction", () => {
21
+ const n: [number, number, number] = [0, 1, 0];
22
+ orientNormalAgainstRay(n, 0, -20, 0);
23
+ expect(n[1]).toBeCloseTo(1, 6);
24
+ });
25
+
26
+ it("leaves a zero-length normal as the zero vector (no divide-by-zero)", () => {
27
+ const n: [number, number, number] = [0, 0, 0];
28
+ orientNormalAgainstRay(n, 0, -1, 0);
29
+ expect(n).toEqual([0, 0, 0]);
30
+ });
31
+ });
@@ -0,0 +1,18 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+
3
+ /**
4
+ * Normalize `n` in place and flip it so it opposes the ray direction
5
+ * `(dx,dy,dz)` — the standard picking convention (the returned normal points
6
+ * back toward the ray origin, i.e. outward from the hit surface).
7
+ *
8
+ * Unifies the normal convention across engines and across ray-cast vs.
9
+ * shape-cast, whose raw normals (surface normal, contact normal, penetration
10
+ * axis) don't otherwise agree on sign. A zero-length input is left as the zero
11
+ * vector (guarded so a degenerate hit doesn't divide by zero).
12
+ */
13
+ export const orientNormalAgainstRay = (n: [number, number, number], dx: number, dy: number, dz: number): void => {
14
+ const len = Math.hypot(n[0], n[1], n[2]);
15
+ if (len === 0) return;
16
+ n[0] /= len; n[1] /= len; n[2] /= len;
17
+ if (n[0] * dx + n[1] * dy + n[2] * dz > 0) { n[0] = -n[0]; n[1] = -n[1]; n[2] = -n[2]; }
18
+ };
@@ -0,0 +1,200 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+
3
+ import { describe, it, expect, beforeAll } from "vitest";
4
+ import { Database } from "@adobe/data/ecs";
5
+ import type { Line3 } from "@adobe/data/math";
6
+ import { rapierSolver } from "./rapier-solver-plugin.js";
7
+ import { joltSolver } from "./jolt-solver-plugin.js";
8
+ import type { PhysicsHit } from "../physics-hit.js";
9
+
10
+ /**
11
+ * Abstract picking conformance suite: the SAME expected outcomes are asserted
12
+ * against every solver behind the `physicsData` seam. Building a fixed static
13
+ * scene and running identical `pickRay` calls proves both engines fulfil the
14
+ * `PhysicsQuery` contract interchangeably (entity, parametric α, hit point,
15
+ * and a ray-opposing normal), including the with/without-radius variants.
16
+ *
17
+ * npx vitest --run src/physics/solvers/pick-ray-conformance.test.ts
18
+ */
19
+
20
+ // The solver plugin extends `physicsData`, so the StaticCollider archetype, the
21
+ // `frameTime` resource, and the `pickRay` action all exist on the created db.
22
+ // The scene is driven dynamically, so the db is used through a loose shape
23
+ // (runtime invariant: these members exist on any physicsData solver).
24
+ interface LooseDb {
25
+ store: {
26
+ archetypes: Record<string, { insert(v: unknown): number }>;
27
+ resources: Record<string, unknown>;
28
+ };
29
+ system: { functions: Record<string, () => unknown>; order?: string[][] };
30
+ actions: {
31
+ pickRay(args: { ray: Line3; radius?: number }): PhysicsHit | null;
32
+ pickRayEach(args: { ray: Line3; visit: (hit: PhysicsHit) => boolean | void; radius?: number }): void;
33
+ };
34
+ }
35
+
36
+ const yieldToEventLoop = () => new Promise<void>(r => setTimeout(r, 0));
37
+
38
+ interface Scene { db: LooseDb; floor: number; ball: number; box: number }
39
+
40
+ // Build a fixed scene of static colliders and drive the sim until the solver's
41
+ // WASM world is live, the bodies are mirrored, and the query pipeline is
42
+ // populated (both engines need at least one step for that). Picks are read-only,
43
+ // so one scene is shared across all cases for a solver.
44
+ async function buildScene(solver: Database.Plugin): Promise<Scene> {
45
+ const db = Database.create(solver) as unknown as LooseDb;
46
+
47
+ // Floor: wide thick box, top face at y = 0.
48
+ const floor = db.store.archetypes.StaticCollider.insert({
49
+ colliderShape: "box", halfExtents: [10, 1, 10], material: 0,
50
+ position: [0, -1, 0], rotation: [0, 0, 0, 1],
51
+ });
52
+ // Sphere radius 1 centred at (0, 3, 0) → top at y = 4.
53
+ const ball = db.store.archetypes.StaticCollider.insert({
54
+ colliderShape: "sphere", halfExtents: [1, 0, 0], material: 0,
55
+ position: [0, 3, 0], rotation: [0, 0, 0, 1],
56
+ });
57
+ // Unit box centred at (5, 3, 0) → top at y = 4, off to the side.
58
+ const box = db.store.archetypes.StaticCollider.insert({
59
+ colliderShape: "box", halfExtents: [1, 1, 1], material: 0,
60
+ position: [5, 3, 0], rotation: [0, 0, 0, 1],
61
+ });
62
+
63
+ const order = db.system.order;
64
+ const names = order ? order.flat() : Object.keys(db.system.functions);
65
+ const runNames = names.filter(n => n !== "_frameTime" && n !== "schedulerSystem");
66
+ const dt = 1 / 60;
67
+ const tick = (f: number): void => {
68
+ db.store.resources.frameTime = { now: f * dt * 1000, dt, elapsed: f * dt };
69
+ for (const n of runNames) { const fn = db.system.functions[n]; if (fn) fn(); }
70
+ };
71
+
72
+ // Drive until the solver publishes its query, then a few more steps so the
73
+ // bodies are mirrored and the broad/narrow phase is populated.
74
+ let f = 0;
75
+ for (; f < 300 && db.store.resources.physicsQuery == null; f++) { tick(f); await yieldToEventLoop(); }
76
+ for (let k = 0; k < 30; k++, f++) { tick(f); await yieldToEventLoop(); }
77
+
78
+ return { db, floor, ball, box };
79
+ }
80
+
81
+ function describePicking(name: string, solver: Database.Plugin): void {
82
+ describe(`pickRay conformance — ${name}`, () => {
83
+ let scene: Scene;
84
+ beforeAll(async () => { scene = await buildScene(solver); }, 60_000);
85
+
86
+ it("publishes a physicsQuery once the world is live", () => {
87
+ expect((scene.db.store.resources as { physicsQuery: unknown }).physicsQuery).not.toBeNull();
88
+ });
89
+
90
+ it("hits the sphere with a ray straight down through its centre", () => {
91
+ // segment y: 10 → −10 (span 20); sphere top at y = 4 ⇒ α = (10 − 4) / 20 = 0.3
92
+ const ray: Line3 = { a: [0, 10, 0], b: [0, -10, 0] };
93
+ const hit = scene.db.actions.pickRay({ ray });
94
+ expect(hit).not.toBeNull();
95
+ expect(hit!.entity).toBe(scene.ball);
96
+ expect(hit!.distance).toBeCloseTo(0.3, 1);
97
+ expect(hit!.point[1]).toBeCloseTo(4, 1);
98
+ // ray points down, so the surface normal points up (opposes the ray)
99
+ expect(hit!.normal[1]).toBeGreaterThan(0.5);
100
+ });
101
+
102
+ it("hits the box off to the side", () => {
103
+ const ray: Line3 = { a: [5, 10, 0], b: [5, -10, 0] };
104
+ const hit = scene.db.actions.pickRay({ ray });
105
+ expect(hit).not.toBeNull();
106
+ expect(hit!.entity).toBe(scene.box);
107
+ expect(hit!.point[1]).toBeCloseTo(4, 1); // box top face
108
+ expect(hit!.normal[1]).toBeGreaterThan(0.5);
109
+ });
110
+
111
+ it("returns the nearest collider when several lie along the ray", () => {
112
+ // straight down through the centre passes the sphere (top y = 4) before
113
+ // the floor (top y = 0) → the nearer sphere wins
114
+ const hit = scene.db.actions.pickRay({ ray: { a: [0, 10, 0], b: [0, -10, 0] } });
115
+ expect(hit!.entity).toBe(scene.ball);
116
+ });
117
+
118
+ it("returns null when the ray misses every collider", () => {
119
+ const ray: Line3 = { a: [100, 10, 100], b: [100, -10, 100] };
120
+ expect(scene.db.actions.pickRay({ ray })).toBeNull();
121
+ });
122
+
123
+ it("with/without radius: a grazing ray misses as a line but hits as a thick ray", () => {
124
+ // Short segment offset 1.4 in x from the sphere centre, kept above the
125
+ // floor (y: 5 → 2.5). As a thin line it clears the sphere (1.4 > radius 1)
126
+ // and never reaches the floor → miss. A 0.5-radius sweep has effective
127
+ // reach 1.5 > 1.4 → it grazes the sphere.
128
+ const ray: Line3 = { a: [1.4, 5, 0], b: [1.4, 2.5, 0] };
129
+ expect(scene.db.actions.pickRay({ ray })).toBeNull();
130
+
131
+ const hit = scene.db.actions.pickRay({ ray, radius: 0.5 });
132
+ expect(hit).not.toBeNull();
133
+ expect(hit!.entity).toBe(scene.ball);
134
+ expect(hit!.distance).toBeGreaterThanOrEqual(0);
135
+ expect(hit!.distance).toBeLessThanOrEqual(1);
136
+ });
137
+
138
+ it("castRayEach visits every collider along the ray, nearest first", () => {
139
+ // straight down at x = 0 crosses the sphere (top y = 4, α = 0.3) then the
140
+ // floor (top y = 0, α = 0.5); the box at x = 5 is not on the ray
141
+ const seen: number[] = [];
142
+ const dists: number[] = [];
143
+ scene.db.actions.pickRayEach({
144
+ ray: { a: [0, 10, 0], b: [0, -10, 0] },
145
+ visit: (h) => { seen.push(h.entity); dists.push(h.distance); },
146
+ });
147
+ expect(seen).toEqual([scene.ball, scene.floor]);
148
+ expect(dists).toEqual([...dists].sort((x, y) => x - y)); // ascending
149
+ });
150
+
151
+ it("castRayEach stops early when visit returns false", () => {
152
+ const seen: number[] = [];
153
+ scene.db.actions.pickRayEach({
154
+ ray: { a: [0, 10, 0], b: [0, -10, 0] },
155
+ visit: (h) => { seen.push(h.entity); return false; }, // stop after the nearest
156
+ });
157
+ expect(seen).toEqual([scene.ball]);
158
+ });
159
+
160
+ it("castRayEach visits nothing when the ray misses everything", () => {
161
+ let count = 0;
162
+ scene.db.actions.pickRayEach({
163
+ ray: { a: [100, 10, 100], b: [100, -10, 100] },
164
+ visit: () => { count++; },
165
+ });
166
+ expect(count).toBe(0);
167
+ });
168
+
169
+ it("castRayEach with radius visits at least the nearest swept hit on every solver", () => {
170
+ // The Rapier compat binding can't sweep-all, so radius degrades to the
171
+ // nearest there; Jolt returns all. Both must at least visit the nearest.
172
+ const seen: number[] = [];
173
+ scene.db.actions.pickRayEach({
174
+ ray: { a: [1.4, 5, 0], b: [1.4, 2.5, 0] },
175
+ radius: 0.5,
176
+ visit: (h) => { seen.push(h.entity); },
177
+ });
178
+ expect(seen.length).toBeGreaterThanOrEqual(1);
179
+ expect(seen[0]).toBe(scene.ball);
180
+ });
181
+
182
+ it("castRayEach reuses a single hit instance across visits (zero-alloc contract)", () => {
183
+ const refs: PhysicsHit[] = [];
184
+ scene.db.actions.pickRayEach({ ray: { a: [0, 10, 0], b: [0, -10, 0] }, visit: (h) => { refs.push(h); } });
185
+ expect(refs.length).toBe(2);
186
+ expect(refs[0]).toBe(refs[1]); // same object, refilled per visit — do not retain
187
+ });
188
+
189
+ it("castRay returns fresh, independent hits (safe to retain)", () => {
190
+ const h1 = scene.db.actions.pickRay({ ray: { a: [0, 10, 0], b: [0, -10, 0] } });
191
+ const h2 = scene.db.actions.pickRay({ ray: { a: [5, 10, 0], b: [5, -10, 0] } });
192
+ expect(h1).not.toBe(h2);
193
+ expect(h1!.entity).toBe(scene.ball); // unaffected by the second pick
194
+ expect(h2!.entity).toBe(scene.box);
195
+ });
196
+ });
197
+ }
198
+
199
+ describePicking("rapierSolver", rapierSolver);
200
+ describePicking("joltSolver", joltSolver);
@@ -2,6 +2,7 @@
2
2
 
3
3
  import RAPIER from "@dimforge/rapier3d-compat";
4
4
  import { Database, type Entity } from "@adobe/data/ecs";
5
+ import type { Line3, Vec3 } from "@adobe/data/math";
5
6
  import { isVoxelShapePhysicsPending } from "../is-voxel-shape-physics-pending.js";
6
7
  import { True } from "@adobe/data/schema";
7
8
  import { physicsClock } from "../physics-clock-plugin.js";
@@ -9,6 +10,10 @@ import { physicsData } from "../physics-data-plugin.js";
9
10
  import { jointData } from "../joint/joint-plugin.js";
10
11
  import { BodyType } from "../body/body-type/body-type.js";
11
12
  import { ColliderShape } from "../body/collider-shape/collider-shape.js";
13
+ import type { PhysicsQuery } from "../physics-query.js";
14
+ import type { PhysicsHit } from "../physics-hit.js";
15
+ import { orientNormalAgainstRay } from "./orient-normal-against-ray.js";
16
+ import { newHit, type MutableHit } from "./mutable-hit.js";
12
17
 
13
18
  /**
14
19
  * A second rigid-body solver behind the same `physicsData` seam — the
@@ -48,8 +53,8 @@ interface MatProps { density: number; restitution: number; friction: number }
48
53
  export const rapierSolver = Database.Plugin.create({
49
54
  extends: Database.Plugin.combine(physicsData, jointData, physicsClock),
50
55
  components: {
51
- _rapierBody: { ...True.schema, nonPersistent: true }, // tag: this body has been mirrored into the Rapier world
52
- _rapierJoint: { ...True.schema, nonPersistent: true }, // tag: this joint has been mirrored into the Rapier world
56
+ _rapierBody: { ...True.schema, default: undefined, nonPersistent: true }, // tag: this body has been mirrored into the Rapier world
57
+ _rapierJoint: { ...True.schema, default: undefined, nonPersistent: true }, // tag: this joint has been mirrored into the Rapier world
53
58
  },
54
59
  systems: {
55
60
  rapierStep: {
@@ -84,6 +89,7 @@ export const rapierSolver = Database.Plugin.create({
84
89
  desc.setTranslation(px, py, pz).setRotation({ x: q[0], y: q[1], z: q[2], w: q[3] });
85
90
  if (motion === "dynamic") { desc.setLinvel(vx, vy, vz); desc.setAngvel({ x: wx, y: wy, z: wz }); }
86
91
  const body = world.createRigidBody(desc);
92
+ body.userData = id; // reverse lookup for ray-pick: collider → parent body → entity
87
93
  // capsule: Y-aligned, halfHeight = cylinder half (hy), radius = hx.
88
94
  // hull: convex hull of the authored point cloud (read once here).
89
95
  let col: RAPIER.ColliderDesc | null;
@@ -107,11 +113,96 @@ export const rapierSolver = Database.Plugin.create({
107
113
  bodies.set(id, body);
108
114
  };
109
115
 
116
+ // Solver-agnostic ray-pick over the live Rapier world. Passing the
117
+ // segment vector (b − a) as the ray/shape-cast direction and capping
118
+ // the time-of-impact at 1 makes the returned toi the parametric α ∈
119
+ // [0,1] along the segment directly (matching PhysicsHit's convention).
120
+ const makeQuery = (w: RAPIER.World): PhysicsQuery => {
121
+ // Fill `out` with a thin-ray hit. userData was set to the entity id
122
+ // when the body was mirrored. Normal is oriented to oppose the ray.
123
+ const fillRay = (out: MutableHit, entity: Entity, alpha: number, nx: number, ny: number, nz: number, a: Vec3, dx: number, dy: number, dz: number): void => {
124
+ out.entity = entity; out.distance = alpha;
125
+ out.point[0] = a[0] + dx * alpha; out.point[1] = a[1] + dy * alpha; out.point[2] = a[2] + dz * alpha;
126
+ out.normal[0] = nx; out.normal[1] = ny; out.normal[2] = nz;
127
+ orientNormalAgainstRay(out.normal, dx, dy, dz);
128
+ };
129
+ const nearest = (ray: Line3, options?: { radius?: number }): PhysicsHit | null => {
130
+ const a = ray.a, b = ray.b;
131
+ const dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
132
+ const radius = options?.radius ?? 0;
133
+ const out = newHit();
134
+ if (radius > 0) {
135
+ // sphere of `radius` swept along the segment
136
+ const hit = w.castShape(
137
+ { x: a[0], y: a[1], z: a[2] }, { x: 0, y: 0, z: 0, w: 1 }, { x: dx, y: dy, z: dz },
138
+ new RAPIER.Ball(radius), 0, 1, true,
139
+ );
140
+ if (!hit) return null;
141
+ const bod = hit.collider.parent();
142
+ if (!bod) return null;
143
+ const alpha = hit.time_of_impact;
144
+ out.entity = bod.userData as Entity; out.distance = alpha;
145
+ out.normal[0] = hit.normal1.x; out.normal[1] = hit.normal1.y; out.normal[2] = hit.normal1.z;
146
+ orientNormalAgainstRay(out.normal, dx, dy, dz);
147
+ // pull the swept-sphere centre at impact back onto the collider surface
148
+ out.point[0] = a[0] + dx * alpha - out.normal[0] * radius;
149
+ out.point[1] = a[1] + dy * alpha - out.normal[1] * radius;
150
+ out.point[2] = a[2] + dz * alpha - out.normal[2] * radius;
151
+ return out;
152
+ }
153
+ const hit = w.castRayAndGetNormal(new RAPIER.Ray({ x: a[0], y: a[1], z: a[2] }, { x: dx, y: dy, z: dz }), 1, true);
154
+ if (!hit) return null;
155
+ const bod = hit.collider.parent();
156
+ if (!bod) return null;
157
+ fillRay(out, bod.userData as Entity, hit.timeOfImpact, hit.normal.x, hit.normal.y, hit.normal.z, a, dx, dy, dz);
158
+ return out;
159
+ };
160
+ // Zero-alloc scratch for castRayEach: one reused hit + a grow-only pool
161
+ // of records. intersectionsWithRay reports crossings in broadphase-
162
+ // arbitrary order, so records are collected then index-sorted near→far.
163
+ const scratch = newHit();
164
+ const pool: { entity: Entity; distance: number; nx: number; ny: number; nz: number }[] = [];
165
+ const order: number[] = [];
166
+ return {
167
+ castRay: nearest,
168
+ castRayEach(ray: Line3, visit, options): void {
169
+ // The compat binding has no swept-shape all-hits query, so a
170
+ // radius query degrades to the nearest hit (see PhysicsQuery).
171
+ if ((options?.radius ?? 0) > 0) { const h = nearest(ray, options); if (h) visit(h); return; }
172
+ const a = ray.a, b = ray.b;
173
+ const dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
174
+ let count = 0;
175
+ w.intersectionsWithRay(new RAPIER.Ray({ x: a[0], y: a[1], z: a[2] }, { x: dx, y: dy, z: dz }), 1, true, (isect: RAPIER.RayColliderIntersection) => {
176
+ const bod = isect.collider.parent();
177
+ if (bod) {
178
+ let rec = pool[count];
179
+ if (!rec) { rec = { entity: 0 as Entity, distance: 0, nx: 0, ny: 0, nz: 0 }; pool[count] = rec; }
180
+ rec.entity = bod.userData as Entity; rec.distance = isect.timeOfImpact;
181
+ rec.nx = isect.normal.x; rec.ny = isect.normal.y; rec.nz = isect.normal.z;
182
+ count++;
183
+ }
184
+ return true; // keep collecting
185
+ });
186
+ for (let i = 0; i < count; i++) order[i] = i;
187
+ order.length = count;
188
+ order.sort((i, j) => pool[i].distance - pool[j].distance);
189
+ for (let k = 0; k < count; k++) {
190
+ const rec = pool[order[k]];
191
+ fillRay(scratch, rec.entity, rec.distance, rec.nx, rec.ny, rec.nz, a, dx, dy, dz);
192
+ if (visit(scratch) === false) break;
193
+ }
194
+ },
195
+ };
196
+ };
197
+
110
198
  return () => {
111
199
  if (!world) {
112
200
  if (!initStarted) {
113
201
  initStarted = true;
114
- RAPIER.init().then(() => { world = new RAPIER.World({ x: 0, y: -GRAVITY, z: 0 }); });
202
+ RAPIER.init().then(() => {
203
+ world = new RAPIER.World({ x: 0, y: -GRAVITY, z: 0 });
204
+ db.store.resources.physicsQuery = makeQuery(world);
205
+ });
115
206
  }
116
207
  return; // WASM not ready yet
117
208
  }