math_utils.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. from __future__ import annotations
  2. import math
  3. import random
  4. class CubicBezier:
  5. """Cubic Bezier curve solver for smooth animation timing.
  6. Based on UnitBezier from WebKit/Chromium. Maps a time progress value
  7. to an eased progress value using a cubic Bezier curve.
  8. """
  9. def __init__(self, point1_x: float, point1_y: float, point2_x: float, point2_y: float):
  10. self.coefficient_c_x = 3.0 * point1_x
  11. self.coefficient_b_x = 3.0 * (point2_x - point1_x) - self.coefficient_c_x
  12. self.coefficient_a_x = 1.0 - self.coefficient_c_x - self.coefficient_b_x
  13. self.coefficient_c_y = 3.0 * point1_y
  14. self.coefficient_b_y = 3.0 * (point2_y - point1_y) - self.coefficient_c_y
  15. self.coefficient_a_y = 1.0 - self.coefficient_c_y - self.coefficient_b_y
  16. def sample_curve_x(self, time_progress: float) -> float:
  17. return (
  18. (self.coefficient_a_x * time_progress + self.coefficient_b_x) * time_progress
  19. + self.coefficient_c_x
  20. ) * time_progress
  21. def sample_curve_y(self, time_progress: float) -> float:
  22. return (
  23. (self.coefficient_a_y * time_progress + self.coefficient_b_y) * time_progress
  24. + self.coefficient_c_y
  25. ) * time_progress
  26. def sample_curve_derivative_x(self, time_progress: float) -> float:
  27. return (
  28. 3.0 * self.coefficient_a_x * time_progress + 2.0 * self.coefficient_b_x
  29. ) * time_progress + self.coefficient_c_x
  30. def solve_curve_x(self, target_x: float, epsilon: float = 1e-6) -> float:
  31. """Given an x value, find the corresponding t value."""
  32. estimated_t = target_x
  33. for _ in range(8):
  34. current_x = self.sample_curve_x(estimated_t) - target_x
  35. if abs(current_x) < epsilon:
  36. return estimated_t
  37. derivative = self.sample_curve_derivative_x(estimated_t)
  38. if abs(derivative) < epsilon:
  39. break
  40. estimated_t -= current_x / derivative
  41. lower_bound = 0.0
  42. upper_bound = 1.0
  43. estimated_t = target_x
  44. if estimated_t < lower_bound:
  45. return lower_bound
  46. if estimated_t > upper_bound:
  47. return upper_bound
  48. while lower_bound < upper_bound:
  49. current_x = self.sample_curve_x(estimated_t)
  50. if abs(current_x - target_x) < epsilon:
  51. return estimated_t
  52. if target_x > current_x:
  53. lower_bound = estimated_t
  54. else:
  55. upper_bound = estimated_t
  56. estimated_t = (upper_bound - lower_bound) * 0.5 + lower_bound
  57. return estimated_t
  58. def solve(self, input_x: float) -> float:
  59. """Get y value for a given x (time progress)."""
  60. return self.sample_curve_y(self.solve_curve_x(input_x))
  61. def minimum_jerk(t: float) -> float:
  62. """Minimum jerk position at normalized time t in [0,1].
  63. Returns 10t^3 - 15t^4 + 6t^5 which produces a bell-shaped velocity
  64. profile: slow start, peak in middle, slow end.
  65. """
  66. t2 = t * t
  67. t3 = t2 * t
  68. return 10.0 * t3 - 15.0 * t3 * t + 6.0 * t3 * t2
  69. def bezier_2d(
  70. t: float,
  71. p0: tuple[float, float],
  72. p1: tuple[float, float],
  73. p2: tuple[float, float],
  74. p3: tuple[float, float],
  75. ) -> tuple[float, float]:
  76. """Evaluate 2D cubic Bezier at parameter t.
  77. B(t) = (1-t)^3*P0 + 3(1-t)^2*t*P1 + 3(1-t)*t^2*P2 + t^3*P3
  78. """
  79. u = 1.0 - t
  80. u2 = u * u
  81. u3 = u2 * u
  82. t2 = t * t
  83. t3 = t2 * t
  84. x = u3 * p0[0] + 3.0 * u2 * t * p1[0] + 3.0 * u * t2 * p2[0] + t3 * p3[0]
  85. y = u3 * p0[1] + 3.0 * u2 * t * p1[1] + 3.0 * u * t2 * p2[1] + t3 * p3[1]
  86. return (x, y)
  87. def fitts_duration(
  88. distance: float,
  89. target_width: float,
  90. a: float,
  91. b: float,
  92. ) -> float:
  93. """Fitts's Law: MT = a + b * log2(D/W + 1)."""
  94. if distance <= 0:
  95. return a
  96. return a + b * math.log2(distance / target_width + 1.0)
  97. def random_control_points(
  98. start: tuple[float, float],
  99. end: tuple[float, float],
  100. curvature_min: float,
  101. curvature_max: float,
  102. curvature_asymmetry: float,
  103. short_distance_threshold: float,
  104. ) -> tuple[tuple[float, float], tuple[float, float]]:
  105. """Generate randomized 2D Bezier control points for a curved mouse path.
  106. Control points are offset perpendicular to the start-end line.
  107. The first control point is biased earlier along the path
  108. (ballistic phase asymmetry).
  109. """
  110. dx = end[0] - start[0]
  111. dy = end[1] - start[1]
  112. distance = math.hypot(dx, dy)
  113. if distance < 1.0:
  114. return (start, end)
  115. perp = (-dy / distance, dx / distance)
  116. scale = min(1.0, distance / short_distance_threshold)
  117. offsets = (
  118. random.uniform(curvature_min, curvature_max) * distance * scale,
  119. random.uniform(curvature_min, curvature_max) * distance * scale,
  120. )
  121. sign = random.choice([-1.0, 1.0])
  122. t1 = random.uniform(0.2, curvature_asymmetry)
  123. t2 = random.uniform(curvature_asymmetry, 0.8)
  124. cp1 = (
  125. start[0] + dx * t1 + perp[0] * offsets[0] * sign,
  126. start[1] + dy * t1 + perp[1] * offsets[0] * sign,
  127. )
  128. counter = random.uniform(0.3, 1.0)
  129. cp2 = (
  130. start[0] + dx * t2 + perp[0] * offsets[1] * sign * counter,
  131. start[1] + dy * t2 + perp[1] * offsets[1] * sign * counter,
  132. )
  133. return (cp1, cp2)