Skip to content

Commit 5553fba

Browse files
committed
[add] backend raycasting implementation.
1 parent a616a9e commit 5553fba

4 files changed

Lines changed: 309 additions & 7 deletions

File tree

crates/lambda-rs-platform/src/physics/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod rapier2d;
99
pub use rapier2d::{
1010
Collider2DBackendError,
1111
PhysicsBackend2D,
12+
RaycastHit2DBackend,
1213
RigidBody2DBackendError,
1314
RigidBodyType2D,
1415
};

crates/lambda-rs-platform/src/physics/rapier2d.rs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,21 @@ impl fmt::Display for Collider2DBackendError {
103103

104104
impl Error for Collider2DBackendError {}
105105

106+
/// Backend-agnostic data describing the nearest 2D raycast hit.
107+
#[derive(Debug, Clone, Copy, PartialEq)]
108+
pub struct RaycastHit2DBackend {
109+
/// The hit rigid body's slot index.
110+
pub body_slot_index: u32,
111+
/// The hit rigid body's slot generation.
112+
pub body_slot_generation: u32,
113+
/// The world-space hit point.
114+
pub point: [f32; 2],
115+
/// The world-space unit hit normal.
116+
pub normal: [f32; 2],
117+
/// The non-negative hit distance in meters.
118+
pub distance: f32,
119+
}
120+
106121
/// The fallback mass applied to dynamic bodies before density colliders exist.
107122
const DYNAMIC_BODY_FALLBACK_MASS_KG: f32 = 1.0;
108123

@@ -703,6 +718,82 @@ impl PhysicsBackend2D {
703718
return body_slots;
704719
}
705720

721+
/// Returns the nearest rigid body hit by the provided finite ray segment.
722+
///
723+
/// This iterates the live collider set directly instead of using Rapier's
724+
/// broad-phase query pipeline. The overlap-query work already established
725+
/// that direct iteration is the most reliable way to support read-only
726+
/// queries immediately after collider creation, before any simulation step
727+
/// has synchronized broad-phase acceleration structures.
728+
///
729+
/// # Arguments
730+
/// - `origin`: The world-space ray origin.
731+
/// - `dir`: The world-space ray direction.
732+
/// - `max_dist`: The maximum query distance in meters.
733+
///
734+
/// # Returns
735+
/// Returns the nearest hit data when any live collider intersects the ray.
736+
pub fn raycast_2d(
737+
&self,
738+
origin: [f32; 2],
739+
dir: [f32; 2],
740+
max_dist: f32,
741+
) -> Option<RaycastHit2DBackend> {
742+
if validate_position(origin[0], origin[1]).is_err()
743+
|| validate_velocity(dir[0], dir[1]).is_err()
744+
|| !max_dist.is_finite()
745+
|| max_dist <= 0.0
746+
{
747+
return None;
748+
}
749+
750+
let normalized_dir = normalize_query_vector_2d(dir)?;
751+
let ray = Ray::new(
752+
Vector::new(origin[0], origin[1]),
753+
Vector::new(normalized_dir[0], normalized_dir[1]),
754+
);
755+
let mut nearest_hit = None;
756+
757+
for (collider_handle, collider) in self.colliders.iter() {
758+
// Resolve the public body handle data up front so the final hit payload
759+
// stays backend-agnostic and does not expose Rapier collider handles.
760+
let Some(body_slot) =
761+
self.query_hit_to_parent_body_slot_2d(collider_handle)
762+
else {
763+
continue;
764+
};
765+
766+
let Some(hit) =
767+
cast_live_collider_raycast_hit_2d(collider, &ray, max_dist)
768+
else {
769+
continue;
770+
};
771+
let hit_point = ray.point_at(hit.time_of_impact);
772+
let candidate = RaycastHit2DBackend {
773+
body_slot_index: body_slot.0,
774+
body_slot_generation: body_slot.1,
775+
point: [hit_point.x, hit_point.y],
776+
normal: [hit.normal.x, hit.normal.y],
777+
distance: hit.time_of_impact,
778+
};
779+
780+
// The public API only returns the nearest hit, so keep the first minimum
781+
// distance we observe while scanning the live collider set.
782+
if nearest_hit
783+
.as_ref()
784+
.is_some_and(|nearest: &RaycastHit2DBackend| {
785+
candidate.distance >= nearest.distance
786+
})
787+
{
788+
continue;
789+
}
790+
791+
nearest_hit = Some(candidate);
792+
}
793+
794+
return nearest_hit;
795+
}
796+
706797
/// Sets the current position for the referenced body.
707798
///
708799
/// # Arguments
@@ -1477,6 +1568,73 @@ fn validate_velocity(x: f32, y: f32) -> Result<(), RigidBody2DBackendError> {
14771568
return Ok(());
14781569
}
14791570

1571+
/// Normalizes a finite 2D query vector.
1572+
///
1573+
/// # Arguments
1574+
/// - `vector`: The vector to normalize.
1575+
///
1576+
/// # Returns
1577+
/// Returns the normalized vector when the input has non-zero finite length.
1578+
///
1579+
/// Ray queries normalize directions so Rapier's `time_of_impact` value matches
1580+
/// the world-space travel distance expected by the public API.
1581+
fn normalize_query_vector_2d(vector: [f32; 2]) -> Option<[f32; 2]> {
1582+
let length = vector[0].hypot(vector[1]);
1583+
1584+
if !length.is_finite() || length <= 0.0 {
1585+
return None;
1586+
}
1587+
1588+
return Some([vector[0] / length, vector[1] / length]);
1589+
}
1590+
1591+
/// Casts a ray against one live collider and normalizes the reported normal.
1592+
///
1593+
/// When the origin lies inside a collider, Parry may report a zero normal for
1594+
/// the solid hit at distance `0.0`. In that case, this helper performs one
1595+
/// non-solid cast along the same ray to recover a stable outward exit normal
1596+
/// while preserving the `0.0` contact distance expected by the public API.
1597+
///
1598+
/// # Arguments
1599+
/// - `collider`: The live Rapier collider to test.
1600+
/// - `ray`: The normalized query ray.
1601+
/// - `max_dist`: The maximum query distance in meters.
1602+
///
1603+
/// # Returns
1604+
/// Returns normalized hit data when the collider intersects the finite ray.
1605+
fn cast_live_collider_raycast_hit_2d(
1606+
collider: &Collider,
1607+
ray: &Ray,
1608+
max_dist: f32,
1609+
) -> Option<RayIntersection> {
1610+
// Use a solid cast so callers starting inside geometry receive an immediate
1611+
// hit at distance `0.0` instead of only the exit point.
1612+
let mut hit = collider.shape().cast_ray_and_get_normal(
1613+
collider.position(),
1614+
ray,
1615+
max_dist,
1616+
true,
1617+
)?;
1618+
1619+
let normalized_normal =
1620+
normalize_query_vector_2d([hit.normal.x, hit.normal.y]).or_else(|| {
1621+
// Some inside hits report a zero normal. A follow-up non-solid cast
1622+
// recovers the exit-face normal without changing the public `0.0`
1623+
// distance contract for origin-inside queries.
1624+
let exit_hit = collider.shape().cast_ray_and_get_normal(
1625+
collider.position(),
1626+
ray,
1627+
max_dist,
1628+
false,
1629+
)?;
1630+
1631+
return normalize_query_vector_2d([exit_hit.normal.x, exit_hit.normal.y]);
1632+
})?;
1633+
1634+
hit.normal = Vector::new(normalized_normal[0], normalized_normal[1]);
1635+
return Some(hit);
1636+
}
1637+
14801638
/// Validates a 2D force vector.
14811639
///
14821640
/// # Arguments

crates/lambda-rs/src/physics/mod.rs

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ use std::{
1313
},
1414
};
1515

16-
use lambda_platform::physics::PhysicsBackend2D;
16+
use lambda_platform::physics::{
17+
PhysicsBackend2D,
18+
RaycastHit2DBackend,
19+
};
1720

1821
mod collider_2d;
1922
mod rigid_body_2d;
@@ -196,13 +199,28 @@ impl PhysicsWorld2D {
196199
///
197200
/// # Returns
198201
/// Returns the nearest hit, if one exists.
202+
///
203+
/// This method preserves an infallible query contract for games: invalid
204+
/// inputs return `None` instead of surfacing backend-specific validation
205+
/// errors through the high-level API.
199206
pub fn raycast(
200207
&self,
201-
_origin: [f32; 2],
202-
_dir: [f32; 2],
203-
_max_dist: f32,
208+
origin: [f32; 2],
209+
dir: [f32; 2],
210+
max_dist: f32,
204211
) -> Option<RaycastHit> {
205-
return None;
212+
if !is_valid_query_point(origin)
213+
|| !is_valid_query_direction(dir)
214+
|| !max_dist.is_finite()
215+
|| max_dist <= 0.0
216+
{
217+
return None;
218+
}
219+
220+
// The backend performs the geometry query and returns backend-neutral hit
221+
// data, which we then bind back to this world's public rigid body handles.
222+
let hit = self.backend.raycast_2d(origin, dir, max_dist)?;
223+
return Some(self.map_backend_raycast_hit(hit));
206224
}
207225

208226
/// Rebuilds and deduplicates rigid body handles from backend query hits.
@@ -234,6 +252,26 @@ impl PhysicsWorld2D {
234252

235253
return bodies;
236254
}
255+
256+
/// Rebuilds a public raycast hit from backend slot and geometry data.
257+
///
258+
/// # Arguments
259+
/// - `hit`: The backend hit payload.
260+
///
261+
/// # Returns
262+
/// Returns a backend-agnostic `RaycastHit`.
263+
fn map_backend_raycast_hit(&self, hit: RaycastHit2DBackend) -> RaycastHit {
264+
return RaycastHit {
265+
body: RigidBody2D::from_backend_slot(
266+
self.world_id,
267+
hit.body_slot_index,
268+
hit.body_slot_generation,
269+
),
270+
point: hit.point,
271+
normal: hit.normal,
272+
distance: hit.distance,
273+
};
274+
}
237275
}
238276

239277
/// Builder for `PhysicsWorld2D`.
@@ -409,6 +447,21 @@ fn is_valid_query_point(point: [f32; 2]) -> bool {
409447
return point[0].is_finite() && point[1].is_finite();
410448
}
411449

450+
/// Returns whether a ray/query direction has finite non-zero length.
451+
///
452+
/// # Arguments
453+
/// - `direction`: The query direction to validate.
454+
///
455+
/// # Returns
456+
/// Returns `true` when both components are finite and the vector is non-zero.
457+
fn is_valid_query_direction(direction: [f32; 2]) -> bool {
458+
if !direction[0].is_finite() || !direction[1].is_finite() {
459+
return false;
460+
}
461+
462+
return direction[0].hypot(direction[1]) > 0.0;
463+
}
464+
412465
/// Allocates a non-zero unique world identifier.
413466
fn allocate_world_id() -> u64 {
414467
loop {

crates/lambda-rs/tests/physics_2d/queries.rs

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
//! Spatial query integration tests.
22
//!
3-
//! These tests validate point and axis-aligned overlap queries through the
4-
//! public `lambda-rs` 2D physics API.
3+
//! These tests validate point queries, overlap queries, and raycasts through
4+
//! the public `lambda-rs` 2D physics API.
55
66
use lambda::physics::{
77
Collider2DBuilder,
88
PhysicsWorld2D,
99
PhysicsWorld2DBuilder,
10+
RaycastHit,
1011
RigidBody2D,
1112
RigidBody2DBuilder,
1213
RigidBodyType,
@@ -172,3 +173,92 @@ fn physics_2d_queries_compound_colliders_return_one_body_handle() {
172173

173174
return;
174175
}
176+
177+
/// Ensures raycasts return the nearest hit body along the segment.
178+
#[test]
179+
fn physics_2d_queries_raycast_returns_nearest_hit() {
180+
let mut world = PhysicsWorld2DBuilder::new()
181+
.with_gravity(0.0, 0.0)
182+
.build()
183+
.unwrap();
184+
185+
let near_circle = build_static_circle(&mut world, [2.0, 0.0], 0.5);
186+
build_static_rectangle(&mut world, [5.0, 0.0], 0.5, 0.5);
187+
188+
let hit = world.raycast([0.0, 0.0], [1.0, 0.0], 10.0).unwrap();
189+
190+
assert_eq!(hit.body, near_circle);
191+
assert_eq!(hit.point, [1.5, 0.0]);
192+
assert_eq!(hit.distance, 1.5);
193+
194+
return;
195+
}
196+
197+
/// Ensures raycast distances are reported in world meters.
198+
#[test]
199+
fn physics_2d_queries_raycast_distance_uses_world_units() {
200+
let mut world = PhysicsWorld2DBuilder::new()
201+
.with_gravity(0.0, 0.0)
202+
.build()
203+
.unwrap();
204+
205+
let circle = build_static_circle(&mut world, [5.0, 0.0], 1.0);
206+
let hit = world.raycast([0.0, 0.0], [2.0, 0.0], 10.0).unwrap();
207+
208+
assert_eq!(hit.body, circle);
209+
assert_eq!(hit.point, [4.0, 0.0]);
210+
assert_eq!(hit.distance, 4.0);
211+
212+
return;
213+
}
214+
215+
/// Ensures raycast normals remain unit length.
216+
#[test]
217+
fn physics_2d_queries_raycast_returns_unit_normal() {
218+
let mut world = PhysicsWorld2DBuilder::new()
219+
.with_gravity(0.0, 0.0)
220+
.build()
221+
.unwrap();
222+
223+
build_static_circle(&mut world, [4.0, 1.0], 1.0);
224+
let hit = world.raycast([0.0, 1.0], [1.0, 0.0], 10.0).unwrap();
225+
226+
assert_unit_normal(hit);
227+
228+
return;
229+
}
230+
231+
/// Ensures solid raycasts report zero distance when starting inside a collider.
232+
#[test]
233+
fn physics_2d_queries_raycast_from_inside_reports_zero_distance() {
234+
let mut world = PhysicsWorld2DBuilder::new()
235+
.with_gravity(0.0, 0.0)
236+
.build()
237+
.unwrap();
238+
239+
let rectangle = build_static_rectangle(&mut world, [0.0, 0.0], 1.0, 1.0);
240+
let hit = world.raycast([0.0, 0.0], [1.0, 0.0], 10.0).unwrap();
241+
242+
assert_eq!(hit.body, rectangle);
243+
assert_eq!(hit.point, [0.0, 0.0]);
244+
assert_eq!(hit.distance, 0.0);
245+
assert_unit_normal(hit);
246+
247+
return;
248+
}
249+
250+
/// Asserts that a raycast hit normal has unit length within tolerance.
251+
///
252+
/// # Arguments
253+
/// - `hit`: The raycast hit to validate.
254+
///
255+
/// # Returns
256+
/// Returns `()` after validating the hit normal length.
257+
fn assert_unit_normal(hit: RaycastHit) {
258+
let normal_length =
259+
(hit.normal[0] * hit.normal[0] + hit.normal[1] * hit.normal[1]).sqrt();
260+
261+
assert!((normal_length - 1.0).abs() <= 1.0e-5);
262+
263+
return;
264+
}

0 commit comments

Comments
 (0)