curltastic/backend/src/physics.rs
Jason Dekarske 5eefcd23bf fix
2026-07-11 13:16:27 -07:00

652 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use rapier2d::prelude::*;
use crate::protocol::*;
const MAX_SIM_TIME: f32 = 30.0;
const REST_SPEED: f32 = 0.04;
const REST_ANGULAR_SPEED: f32 = 0.05;
const G: f32 = 9.80665;
const OOB_SENSOR_TAG: u128 = 1;
/// Initial |ω| for full curl (rad/s). Sign follows curl_sign.
pub const INITIAL_OMEGA: f32 = 0.5 * 2.0 * std::f32::consts::PI;
/// Lateral scale: `v_lat = CURL_LAT_K * µ(speed)` (m/s), applied ⊥ heading.
/// Calibrated so a full-curl tee-line draw drifts ≈ 4 ft.
pub const CURL_LAT_K: f32 = 0.683;
/// Rapier angular linear damping (1/s): dω/dt ≈ ANGULAR_DAMPING · ω.
/// Kept low so spin stays visible through a draw.
pub const ANGULAR_DAMPING: f32 = 0.05;
/// Target lateral displacement (m) for a full-curl draw to the tee line.
#[allow(dead_code)]
pub const CURL_DRAW_LATERAL_M: f32 = 4.0 * FEET_TO_METERS;
/// Ice friction coefficient µ as a function of speed (m/s).
pub fn mu(v: f32) -> f32 {
const KNOTS: [(f32, f32); 7] = [
(0.0, 0.018),
(0.1482, 0.016),
(0.3005, 0.0116),
(0.4486, 0.0098),
(0.7371, 0.0079),
(1.0098, 0.0073),
(2.5, 0.0081),
];
let speed = v.abs();
if speed >= 2.5 {
return 0.0081;
}
for i in 0..KNOTS.len() - 1 {
let (v0, mu0) = KNOTS[i];
let (v1, mu1) = KNOTS[i + 1];
if speed >= v0 && speed <= v1 {
let t = if (v1 - v0).abs() < f32::EPSILON {
0.0
} else {
(speed - v0) / (v1 - v0)
};
return mu0 + t * (mu1 - mu0);
}
}
0.016
}
/// Edge-touch out-of-bounds (sides / back). Hog is checked only at rest.
pub fn edge_out_of_bounds(x: f32, y: f32) -> bool {
let half = SHEET_WIDTH / 2.0;
x.abs() + STONE_RADIUS >= half || y + STONE_RADIUS >= BACK_LINE_Y
}
/// Hog rule: stone must completely clear the hog (trailing edge past the line).
pub fn short_of_hog(y: f32) -> bool {
y - STONE_RADIUS <= HOG_LINE_Y
}
pub struct PhysicsWorld {
gravity: Vector,
integration_parameters: IntegrationParameters,
pipeline: PhysicsPipeline,
islands: IslandManager,
broad_phase: DefaultBroadPhase,
narrow_phase: NarrowPhase,
bodies: RigidBodySet,
colliders: ColliderSet,
impulse_joints: ImpulseJointSet,
multibody_joints: MultibodyJointSet,
ccd_solver: CCDSolver,
next_n_team1: u8,
next_n_team2: u8,
stone_handles: Vec<(StoneId, RigidBodyHandle, Team, i8)>,
/// When false (calibration), no OOB sensors / pruning — open ice.
bounds_enabled: bool,
}
impl Default for PhysicsWorld {
fn default() -> Self {
Self::new()
}
}
impl PhysicsWorld {
pub fn new() -> Self {
Self::with_bounds(true)
}
/// Open sheet for stop-distance calibration (sensors off, no prune).
pub fn new_open() -> Self {
Self::with_bounds(false)
}
fn with_bounds(bounds_enabled: bool) -> Self {
let mut integration_parameters = IntegrationParameters::default();
integration_parameters.dt = PHYSICS_DT;
integration_parameters.num_solver_iterations = 8;
let mut world = Self {
gravity: Vector::new(0.0, 0.0),
integration_parameters,
pipeline: PhysicsPipeline::new(),
islands: IslandManager::new(),
broad_phase: DefaultBroadPhase::new(),
narrow_phase: NarrowPhase::new(),
bodies: RigidBodySet::new(),
colliders: ColliderSet::new(),
impulse_joints: ImpulseJointSet::new(),
multibody_joints: MultibodyJointSet::new(),
ccd_solver: CCDSolver::new(),
next_n_team1: 1,
next_n_team2: 1,
stone_handles: Vec::new(),
bounds_enabled,
};
world.build_sheet();
world
}
pub fn reset(&mut self) {
let bounds_enabled = self.bounds_enabled;
*self = Self::with_bounds(bounds_enabled);
}
pub fn reset_stone_ids(&mut self) {
self.next_n_team1 = 1;
self.next_n_team2 = 1;
}
/// Sensor colliders just outside the playable edge-touch room (no bounce).
fn build_sheet(&mut self) {
if !self.bounds_enabled {
return;
}
let half = SHEET_WIDTH / 2.0;
let r = STONE_RADIUS;
// Center becomes OOB when edge touches lines → sensor starts at half - r / BACK - r.
let side_in = half - r;
let back_in = BACK_LINE_Y - r;
let mid_y = (HOG_LINE_Y + BACK_LINE_Y) * 0.5;
let tall = 80.0_f32;
let thick = 8.0_f32;
let sensors = [
// right
(side_in + thick * 0.5, mid_y, thick * 0.5, tall * 0.5),
// left
(-(side_in + thick * 0.5), mid_y, thick * 0.5, tall * 0.5),
// beyond backline
(0.0, back_in + thick * 0.5, half + thick, thick * 0.5),
];
for (tx, ty, hx, hy) in sensors {
let collider = ColliderBuilder::cuboid(hx, hy)
.translation(Vector::new(tx, ty))
.sensor(true)
.user_data(OOB_SENSOR_TAG)
.build();
self.colliders.insert(collider);
}
}
fn alloc_stone_id(&mut self, team: Team) -> Result<StoneId, String> {
let n = match team {
Team::Team1 => self.next_n_team1,
Team::Team2 => self.next_n_team2,
};
if n > STONES_PER_TEAM {
return Err(format!("no stones remaining for {team}"));
}
match team {
Team::Team1 => self.next_n_team1 = n.saturating_add(1),
Team::Team2 => self.next_n_team2 = n.saturating_add(1),
}
Ok(StoneId { team, n })
}
pub fn throw(
&mut self,
team: Team,
broom_x: f32,
broom_y: f32,
velocity: f32,
curl: i8,
) -> Result<Vec<StonePath>, String> {
let speed = velocity.max(0.0);
let dx = broom_x;
let dy = broom_y - HACK_Y;
let len = (dx * dx + dy * dy).sqrt().max(0.01);
let vx = dx / len * speed;
let vy = dy / len * speed;
let curl_sign = if curl < 0 {
-1
} else if curl > 0 {
1
} else {
0
};
self.spawn_stone(team, 0.0, HACK_Y, vx, vy, curl_sign)
}
fn spawn_stone(
&mut self,
team: Team,
x: f32,
y: f32,
vx: f32,
vy: f32,
curl_sign: i8,
) -> Result<Vec<StonePath>, String> {
let id = self.alloc_stone_id(team)?;
// Trajectory uses curl_sign for lateral drift; ω sign is opposite for
// correct on-ice visual (CW handle → CW granite spin).
let omega0 = -(curl_sign as f32) * INITIAL_OMEGA;
let body = RigidBodyBuilder::dynamic()
.translation(Vector::new(x, y))
.linvel(Vector::new(vx, vy))
.angvel(omega0)
.linear_damping(0.0)
.angular_damping(ANGULAR_DAMPING)
.ccd_enabled(true)
.can_sleep(false)
.build();
let handle = self.bodies.insert(body);
let collider = ColliderBuilder::ball(STONE_RADIUS)
.friction(STONE_FRICTION)
.friction_combine_rule(CoefficientCombineRule::Max)
.restitution(STONE_RESTITUTION)
.restitution_combine_rule(CoefficientCombineRule::Average)
.density(STONE_MASS / (std::f32::consts::PI * STONE_RADIUS * STONE_RADIUS))
.build();
self.colliders
.insert_with_parent(collider, handle, &mut self.bodies);
self.stone_handles.push((id, handle, team, curl_sign));
self.simulate_until_rest()
}
fn simulate_until_rest(&mut self) -> Result<Vec<StonePath>, String> {
let sample_step = 1.0 / SAMPLE_RATE_HZ as f32;
let mut sample_accum: f32 = 0.0;
let mut time: f32 = 0.0;
let mut paths: Vec<(StoneId, Team, RigidBodyHandle, Vec<[f32; 3]>)> = self
.stone_handles
.iter()
.map(|(id, handle, team, _)| (*id, *team, *handle, Vec::new()))
.collect();
for (_, _, handle, path) in &mut paths {
if let Some(body) = self.bodies.get(*handle) {
let pos = body.translation();
path.push([pos.x, pos.y, body.rotation().angle()]);
} else {
return Err("stone missing rigid body".into());
}
}
loop {
self.step();
self.apply_ice_friction();
self.apply_curl();
time += PHYSICS_DT;
sample_accum += PHYSICS_DT;
if sample_accum >= sample_step {
sample_accum -= sample_step;
// Always sample every stone for the full shared clock — even when OOB —
// so multi-stone takes don't desync or snap back after prune.
for (_, _, handle, path) in &mut paths {
if let Some(body) = self.bodies.get(*handle) {
let pos = body.translation();
path.push([pos.x, pos.y, body.rotation().angle()]);
}
}
}
if self.all_stones_settled_or_out() || time > MAX_SIM_TIME {
break;
}
}
if self.bounds_enabled {
self.prune_out_of_play();
}
Ok(paths
.into_iter()
.map(|(id, team, handle, trajectory)| {
let rotation = self
.bodies
.get(handle)
.map(|b| b.rotation().angle())
.or_else(|| trajectory.last().map(|s| s[2]))
.unwrap_or(0.0);
StonePath {
stone_id: id,
rotation,
team,
trajectory,
}
})
.collect())
}
fn apply_ice_friction(&mut self) {
for (_, handle, _, _) in &self.stone_handles {
let body = match self.bodies.get_mut(*handle) {
Some(b) => b,
None => continue,
};
let v = body.linvel();
let speed = (v.x * v.x + v.y * v.y).sqrt();
if speed < 1e-6 {
body.set_linvel(Vector::new(0.0, 0.0), true);
continue;
}
let a = mu(speed) * G;
let dv = a * PHYSICS_DT;
if dv >= speed {
body.set_linvel(Vector::new(0.0, 0.0), true);
} else {
let scale = (speed - dv) / speed;
body.set_linvel(Vector::new(v.x * scale, v.y * scale), true);
}
}
}
fn apply_curl(&mut self) {
const MIN_CURL_SPEED: f32 = 0.08;
for (_, handle, _, curl_sign) in &self.stone_handles {
let body = match self.bodies.get_mut(*handle) {
Some(b) => b,
None => continue,
};
let v = body.linvel();
let speed = (v.x * v.x + v.y * v.y).sqrt();
// Spin decay is Rapier angular_damping only; zero at rest for clean settle.
if speed < REST_SPEED {
body.set_angvel(0.0, true);
}
if *curl_sign == 0 || speed < MIN_CURL_SPEED {
continue;
}
let ux = v.x / speed;
let uy = v.y / speed;
// Body-left of heading = (uy, ux). Clockwise curl drifts left when moving +y.
let lx = -uy;
let ly = ux;
let v_lat = (*curl_sign as f32) * CURL_LAT_K * mu(speed);
body.set_linvel(
Vector::new(v.x + lx * v_lat * PHYSICS_DT, v.y + ly * v_lat * PHYSICS_DT),
true,
);
}
}
fn body_hits_oob_sensor(&self, handle: RigidBodyHandle) -> bool {
let Some(body) = self.bodies.get(handle) else {
return false;
};
for &ch in body.colliders() {
for (a, b, intersecting) in self.narrow_phase.intersection_pairs_with(ch) {
if !intersecting {
continue;
}
let other = if a == ch { b } else { a };
if let Some(col) = self.colliders.get(other) {
if col.user_data == OOB_SENSOR_TAG {
return true;
}
}
}
}
false
}
fn prune_out_of_play(&mut self) {
let candidates: Vec<_> = self.stone_handles.drain(..).collect();
let mut keep = Vec::new();
for (id, handle, team, curl) in candidates {
let remove = if let Some(body) = self.bodies.get(handle) {
let pos = body.translation();
edge_out_of_bounds(pos.x, pos.y)
|| short_of_hog(pos.y)
|| self.body_hits_oob_sensor(handle)
} else {
true
};
if remove {
self.bodies.remove(
handle,
&mut self.islands,
&mut self.colliders,
&mut self.impulse_joints,
&mut self.multibody_joints,
true,
);
} else {
keep.push((id, handle, team, curl));
}
}
self.stone_handles = keep;
}
fn step(&mut self) {
self.pipeline.step(
self.gravity,
&self.integration_parameters,
&mut self.islands,
&mut self.broad_phase,
&mut self.narrow_phase,
&mut self.bodies,
&mut self.colliders,
&mut self.impulse_joints,
&mut self.multibody_joints,
&mut self.ccd_solver,
&(),
&(),
);
}
fn all_stones_settled_or_out(&self) -> bool {
for (_, handle, _, _) in &self.stone_handles {
if let Some(body) = self.bodies.get(*handle) {
let pos = body.translation();
if self.bounds_enabled
&& (edge_out_of_bounds(pos.x, pos.y) || self.body_hits_oob_sensor(*handle))
{
continue;
}
let v = body.linvel();
let speed = (v.x * v.x + v.y * v.y).sqrt();
if speed > REST_SPEED || body.angvel().abs() > REST_ANGULAR_SPEED {
return false;
}
}
}
true
}
pub fn current_stones(&self) -> Vec<StoneState> {
let mut states = Vec::new();
for (id, handle, team, _) in &self.stone_handles {
if let Some(body) = self.bodies.get(*handle) {
let pos = body.translation();
states.push(StoneState {
id: *id,
team: *team,
x: pos.x,
y: pos.y,
rotation: body.rotation().angle(),
});
}
}
states
}
pub fn stone_states_for_scoring(&self) -> Vec<(StoneId, Team, f32, f32)> {
let mut out = Vec::new();
for (id, handle, team, _) in &self.stone_handles {
if let Some(body) = self.bodies.get(*handle) {
let pos = body.translation();
out.push((*id, *team, pos.x, pos.y));
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stop_y(velocity: f32) -> f32 {
let mut world = PhysicsWorld::new_open();
world
.throw(Team::Team1, 0.0, HOUSE_CENTER.1, velocity, 0)
.unwrap();
world.current_stones()[0].y
}
fn final_y_in_play(velocity: f32) -> Option<f32> {
let mut world = PhysicsWorld::new();
world
.throw(Team::Team1, 0.0, HOUSE_CENTER.1, velocity, 0)
.unwrap();
world.current_stones().first().map(|s| s.y)
}
#[test]
fn mu_table_endpoints() {
assert!((mu(0.0) - 0.018).abs() < 1e-6);
assert!((mu(2.5) - 0.0081).abs() < 1e-6);
}
/// Calibrates categorical speeds 110 + hack to stop offsets from the tee.
/// Board/control/normal/peel are fixed m/s (look-up only).
#[test]
fn speed_table_lands_at_categorical_distances() {
let ft = FEET_TO_METERS;
let tee = HOUSE_CENTER.1;
let mut distance_labels: Vec<(String, f32, f32)> = WEIGHT_STOP_OFFSET_FT
.iter()
.enumerate()
.map(|(i, off)| {
(
(i + 1).to_string(),
WEIGHT_SPEEDS[i],
tee + off * ft,
)
})
.collect();
distance_labels.push((
"hack".into(),
HACK_SPEED,
tee + HACK_STOP_OFFSET_FT * ft,
));
distance_labels.extend([
(
"board".into(),
BOARD_SPEED,
tee + BOARD_STOP_OFFSET_FT * ft,
),
(
"control".into(),
CONTROL_SPEED,
tee + CONTROL_STOP_OFFSET_FT * ft,
),
(
"normal".into(),
NORMAL_SPEED,
tee + NORMAL_STOP_OFFSET_FT * ft,
),
(
"peel".into(),
PEEL_SPEED,
tee + PEEL_STOP_OFFSET_FT * ft,
),
]);
for (label, speed, target) in &distance_labels {
let y = stop_y(*speed);
let err_ft = (y - target) / ft;
assert!(
err_ft.abs() < 0.35,
"{label}: speed={speed} stop_y={y} target={target} err_ft={err_ft:.2}"
);
}
assert!((DRAW_VELOCITY - WEIGHT_SPEEDS[6]).abs() < 1e-5);
assert!((BOARD_STOP_OFFSET_FT - (HACK_STOP_OFFSET_FT + 6.0)).abs() < 1e-5);
assert!((CONTROL_STOP_OFFSET_FT - (HACK_STOP_OFFSET_FT + 15.0)).abs() < 1e-5);
assert!((NORMAL_STOP_OFFSET_FT - (HACK_STOP_OFFSET_FT + 25.0)).abs() < 1e-5);
assert!((PEEL_STOP_OFFSET_FT - (HACK_STOP_OFFSET_FT + 35.0)).abs() < 1e-5);
}
#[test]
fn edge_touch_backline_is_out() {
assert!(edge_out_of_bounds(0.0, BACK_LINE_Y - STONE_RADIUS + 0.001));
assert!(!edge_out_of_bounds(0.0, BACK_LINE_Y - STONE_RADIUS - 0.01));
}
#[test]
fn hog_edge_rule() {
assert!(short_of_hog(HOG_LINE_Y + STONE_RADIUS - 0.001));
assert!(!short_of_hog(HOG_LINE_Y + STONE_RADIUS + 0.01));
}
#[test]
fn backline_touches_house_ring() {
assert!(
(BACK_LINE_Y - (HOUSE_CENTER.1 + HOUSE_RADIUS)).abs() < 1e-5,
"backline should sit on outer house edge"
);
}
#[test]
fn sensor_bounds_prune_overshoot() {
assert!(final_y_in_play(PEEL_SPEED).is_none());
}
#[test]
fn draw_and_curl_stay_in_play() {
let mut world = PhysicsWorld::new();
world
.throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1)
.unwrap();
assert_eq!(world.current_stones().len(), 1);
}
#[test]
fn tee_line_full_curl_drifts_about_four_feet() {
let mut world = PhysicsWorld::new();
world
.throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 1)
.unwrap();
let s = &world.current_stones()[0];
assert!(
(s.y - HOUSE_CENTER.1).abs() < 1.0,
"should stop near tee, y={}",
s.y
);
// CW curl → body-left (negative x when moving +y).
assert!(
(s.x + CURL_DRAW_LATERAL_M).abs() < 0.3,
"full curl should drift ~-4 ft, got x={} m ({:.2} ft)",
s.x,
s.x / FEET_TO_METERS
);
}
#[test]
fn collision_moves_sitting_stone() {
let mut world = PhysicsWorld::new();
world
.throw(Team::Team1, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0)
.unwrap();
let sit = world.current_stones()[0].clone();
let paths = world
.throw(Team::Team2, sit.x, sit.y, PEEL_SPEED, 0)
.unwrap();
assert!(paths.len() >= 2);
let moved = paths
.iter()
.find(|p| p.stone_id.team == Team::Team1)
.unwrap();
let d = {
let a = moved.trajectory.first().unwrap();
let b = moved.trajectory.last().unwrap();
((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt()
};
assert!(d > 0.2, "struck stone should move, d={d}");
}
}