#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PathStep {
pub x: f64,
pub y: f64,
pub delay_ms: u64,
}
#[derive(Debug, Clone, Copy)]
pub struct HumanizeOptions {
pub curve: f64,
pub tremor: f64,
pub dwell_ms: u64,
pub min_duration_ms: f64,
pub max_duration_ms: f64,
}
impl Default for HumanizeOptions {
fn default() -> Self {
Self {
curve: 22.0,
tremor: 1.1,
dwell_ms: 55,
min_duration_ms: 120.0,
max_duration_ms: 900.0,
}
}
}
#[derive(Debug, Clone)]
pub struct Rng(u64);
impl Rng {
#[must_use]
pub fn seed(seed: u64) -> Self {
Self(seed)
}
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn unit(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
fn range(&mut self, lo: f64, hi: f64) -> f64 {
lo + (hi - lo) * self.unit()
}
}
fn min_jerk(t: f64) -> f64 {
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
}
#[must_use]
pub fn humanized_path(
start: (f64, f64),
end: (f64, f64),
opts: &HumanizeOptions,
rng: &mut Rng,
) -> Vec<PathStep> {
let (x0, y0) = start;
let (x1, y1) = end;
let (dx, dy) = (x1 - x0, y1 - y0);
let dist = dx.hypot(dy);
let steps = ((dist / 9.0).round() as usize).clamp(6, 40);
let duration =
(opts.min_duration_ms + dist * 1.6).clamp(opts.min_duration_ms, opts.max_duration_ms);
let (nx, ny) = if dist > 1e-6 { (-dy / dist, dx / dist) } else { (0.0, 0.0) };
let bow = rng.range(-opts.curve, opts.curve);
let mut out = Vec::with_capacity(steps + 1);
let mut prev_e = 0.0_f64;
for i in 1..=steps {
let t = i as f64 / steps as f64;
let e = min_jerk(t);
let arc = bow * (1.0 - (2.0 * e - 1.0).powi(2));
let damp = 1.0 - 0.65 * e;
let jx = rng.range(-opts.tremor, opts.tremor) * damp;
let jy = rng.range(-opts.tremor, opts.tremor) * damp;
let delay = ((e - prev_e) * duration).max(1.0).round() as u64;
prev_e = e;
out.push(PathStep {
x: x0 + dx * e + nx * arc + jx,
y: y0 + dy * e + ny * arc + jy,
delay_ms: delay,
});
}
if let Some(last) = out.last_mut() {
last.x = x1;
last.y = y1;
}
if opts.dwell_ms > 0 {
out.push(PathStep { x: x1, y: y1, delay_ms: opts.dwell_ms });
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const A: (f64, f64) = (0.0, 0.0);
const B: (f64, f64) = (300.0, 200.0);
#[test]
fn seeded_generation_is_deterministic() {
let opts = HumanizeOptions::default();
let p1 = humanized_path(A, B, &opts, &mut Rng::seed(42));
let p2 = humanized_path(A, B, &opts, &mut Rng::seed(42));
assert_eq!(p1, p2);
}
#[test]
fn different_seeds_differ() {
let opts = HumanizeOptions::default();
let p1 = humanized_path(A, B, &opts, &mut Rng::seed(1));
let p2 = humanized_path(A, B, &opts, &mut Rng::seed(2));
assert_ne!(p1, p2);
}
#[test]
fn emits_multiple_moves_and_lands_exactly_on_target() {
let opts = HumanizeOptions::default();
let path = humanized_path(A, B, &opts, &mut Rng::seed(7));
assert!(path.len() >= 6, "got {} steps", path.len());
let move_steps = &path[..path.len() - 1];
assert!(!move_steps.is_empty(), "humanized path should include movement steps");
let landing = move_steps[move_steps.len() - 1];
assert!((landing.x - B.0).abs() < 1e-9 && (landing.y - B.1).abs() < 1e-9);
}
#[test]
fn duration_is_bounded() {
let opts = HumanizeOptions::default();
let path = humanized_path((0.0, 0.0), (5000.0, 5000.0), &opts, &mut Rng::seed(3));
let total: u64 = path.iter().map(|s| s.delay_ms).sum();
let ceiling = opts.max_duration_ms as u64 + opts.dwell_ms + path.len() as u64;
assert!(total <= ceiling, "total {total} > ceiling {ceiling}");
}
#[test]
fn path_is_curved_not_straight() {
let opts = HumanizeOptions { tremor: 0.0, dwell_ms: 0, ..Default::default() };
let path = humanized_path(A, B, &opts, &mut Rng::seed(11));
let mid = path[path.len() / 2];
let (dx, dy) = (B.0 - A.0, B.1 - A.1);
let len = dx.hypot(dy);
let dev = ((mid.x - A.0) * dy - (mid.y - A.1) * dx).abs() / len;
assert!(dev > 1.0, "midpoint deviation {dev} too small — path is ~straight");
}
}