|
19 | 19 |
|
20 | 20 | # Handle optional pykotor.gl dependency |
21 | 21 | try: |
22 | | - from pykotor.gl.scene.camera import Camera, _wrap_angle_pi |
| 22 | + from pykotor.gl.scene.camera import Camera |
23 | 23 | from pykotor.gl.scene.camera_controller import ( |
24 | 24 | CameraController, |
25 | 25 | CameraControllerSettings, |
@@ -61,11 +61,6 @@ def test_default_values(self): |
61 | 61 | self.assertFalse(state.up_key) |
62 | 62 | self.assertFalse(state.down_key) |
63 | 63 |
|
64 | | - def test_default_pan_button_false(self): |
65 | | - """Virtual pan button defaults off so normal clicks are unchanged.""" |
66 | | - state = InputState() |
67 | | - self.assertFalse(state.pan_button) |
68 | | - |
69 | 64 |
|
70 | 65 | class TestCameraControllerSettings(unittest.TestCase): |
71 | 66 | """Tests for the CameraControllerSettings dataclass.""" |
@@ -158,26 +153,6 @@ def test_initialization(self): |
158 | 153 | self.assertIsNotNone(self.controller.state) |
159 | 154 | self.assertEqual(self.controller.mode, CameraMode.NONE) |
160 | 155 |
|
161 | | - def test_has_pending_motion_instance_attribute(self): |
162 | | - """has_pending_motion is bound in __init__ so callers never hit AttributeError.""" |
163 | | - self.assertTrue( |
164 | | - callable(self.controller.has_pending_motion), |
165 | | - "has_pending_motion must be callable on every instance", |
166 | | - ) |
167 | | - self.assertFalse(self.controller.has_pending_motion()) |
168 | | - |
169 | | - def test_has_pending_motion_true_when_smoothing_lags(self): |
170 | | - """When current state has not caught target, pending motion is reported.""" |
171 | | - self.controller.state.target_focal_point = vec3(100.0, 0.0, 0.0) |
172 | | - self.controller.state.current_focal_point = vec3(0.0, 0.0, 0.0) |
173 | | - self.assertTrue(self.controller.has_pending_motion()) |
174 | | - |
175 | | - def test_has_pending_motion_respects_epsilon(self): |
176 | | - """Sub-epsilon deltas are treated as converged.""" |
177 | | - self.controller.state.current_focal_point = vec3(0.0, 0.0, 0.0) |
178 | | - self.controller.state.target_focal_point = vec3(1e-6, 0.0, 0.0) |
179 | | - self.assertFalse(self.controller.has_pending_motion(epsilon=1e-4)) |
180 | | - |
181 | 156 | def test_mode_detection_orbit_middle_mouse(self): |
182 | 157 | """Test that middle mouse triggers orbit mode.""" |
183 | 158 | input_state = InputState(middle_button=True) |
@@ -206,8 +181,8 @@ def test_mode_detection_pan_shift_middle(self): |
206 | 181 |
|
207 | 182 | self.assertEqual(self.controller.mode, CameraMode.PAN) |
208 | 183 |
|
209 | | - def test_mode_detection_pan_virtual_pan_button(self): |
210 | | - """Virtual pan_button forces pan (e.g. Ctrl+LMB bound by the host).""" |
| 184 | + def test_mode_detection_pan_virtual_button(self): |
| 185 | + """Virtual pan_button forces pan (e.g. Ctrl+LMB bindings).""" |
211 | 186 | input_state = InputState(pan_button=True) |
212 | 187 | self.controller._determine_mode(input_state) |
213 | 188 |
|
@@ -458,47 +433,60 @@ def test_camera_is_updated(self): |
458 | 433 | self.assertEqual(self.camera.z, 45) |
459 | 434 |
|
460 | 435 |
|
461 | | -class TestCameraAngleWrapping(unittest.TestCase): |
462 | | - """Regression tests for camera angle normalization (orbit / free-rotate paths).""" |
463 | | - |
464 | | - def test_wrap_angle_pi_normalizes_to_principal_range(self): |
465 | | - """Angles are folded with (a+pi) % 2pi - pi; +pi maps to -pi (same direction as orbit yaw loops).""" |
466 | | - self.assertAlmostEqual(_wrap_angle_pi(0.0), 0.0, places=9) |
467 | | - self.assertAlmostEqual(_wrap_angle_pi(math.pi), -math.pi, places=9) |
468 | | - self.assertAlmostEqual(_wrap_angle_pi(-math.pi), -math.pi, places=9) |
469 | | - self.assertAlmostEqual(abs(_wrap_angle_pi(3 * math.pi)), math.pi, places=9) |
470 | | - self.assertAlmostEqual(abs(_wrap_angle_pi(-3 * math.pi)), math.pi, places=9) |
471 | | - self.assertAlmostEqual(_wrap_angle_pi(10 * math.pi + 0.1), 0.1, places=9) |
472 | | - |
473 | | - def test_rotate_without_clamp_wraps_yaw_and_pitch(self): |
474 | | - """Large deltas must not leave angles unbounded (prevents float drift in long sessions).""" |
475 | | - cam = Camera() |
476 | | - cam.yaw = 0.0 |
477 | | - cam.pitch = math.pi / 2 |
478 | | - cam.rotate(yaw=4 * math.pi, pitch=0.0, clamp=False) |
479 | | - self.assertAlmostEqual(cam.yaw, 0.0, places=9) |
480 | | - cam.rotate(yaw=0.0, pitch=10.0, clamp=False) |
481 | | - self.assertGreater(cam.pitch, -math.pi) |
482 | | - self.assertLessEqual(cam.pitch, math.pi) |
483 | | - |
484 | | - def test_rotate_with_clamp_respects_pitch_limits(self): |
485 | | - """Clamped orbit pitch must stay inside (lower, upper) even when limits are degenerate.""" |
486 | | - cam = Camera() |
487 | | - cam.pitch = math.pi / 2 |
488 | | - cam.rotate(0.0, 10.0, clamp=True, lower_limit=0.0, upper_limit=math.pi) |
489 | | - self.assertGreater(cam.pitch, 0.0) |
490 | | - self.assertLess(cam.pitch, math.pi) |
491 | | - # Degenerate limits: both sides collapse to a single allowed pitch |
492 | | - cam2 = Camera() |
493 | | - cam2.pitch = math.pi / 2 |
494 | | - cam2.rotate(0.0, 5.0, clamp=True, lower_limit=1.0, upper_limit=1.0) |
495 | | - self.assertAlmostEqual(cam2.pitch, 1.0, places=9) |
| 436 | +class TestCameraRotateAngleWrapping(unittest.TestCase): |
| 437 | + """Regression tests for Camera.rotate yaw/pitch normalization (wrap + clamp).""" |
| 438 | + |
| 439 | + def test_yaw_wraps_full_turn(self): |
| 440 | + """Rotating by 2π should land on the same wrapped yaw as 0.""" |
| 441 | + camera = Camera() |
| 442 | + camera.yaw = 0.0 |
| 443 | + camera.pitch = math.pi / 2 |
| 444 | + camera.rotate(2 * math.pi, 0.0, clamp=False) |
| 445 | + self.assertAlmostEqual(camera.yaw, 0.0, places=6) |
| 446 | + |
| 447 | + def test_yaw_wraps_negative(self): |
| 448 | + """Large positive delta should fold into (-π, π] without runaway values.""" |
| 449 | + camera = Camera() |
| 450 | + camera.yaw = 0.0 |
| 451 | + camera.pitch = math.pi / 2 |
| 452 | + camera.rotate(4.0, 0.0, clamp=False) |
| 453 | + self.assertGreater(camera.yaw, -math.pi - 1e-6) |
| 454 | + self.assertLessEqual(camera.yaw, math.pi + 1e-6) |
| 455 | + |
| 456 | + def test_pitch_wraps_when_not_clamped(self): |
| 457 | + """Without clamp, pitch uses the same π-normalization as yaw.""" |
| 458 | + camera = Camera() |
| 459 | + camera.yaw = 0.0 |
| 460 | + camera.pitch = math.pi / 2 |
| 461 | + camera.rotate(0.0, 3 * math.pi, clamp=False) |
| 462 | + self.assertGreater(camera.pitch, -math.pi - 1e-6) |
| 463 | + self.assertLessEqual(camera.pitch, math.pi + 1e-6) |
| 464 | + |
| 465 | + def test_clamp_inverted_limits_uses_midpoint(self): |
| 466 | + """If lower_limit >= upper_limit, pitch clamps to their midpoint (no crash).""" |
| 467 | + camera = Camera() |
| 468 | + camera.yaw = 0.0 |
| 469 | + camera.pitch = math.pi / 2 |
| 470 | + lower = math.pi * 0.75 |
| 471 | + upper = math.pi * 0.25 |
| 472 | + camera.rotate(0.0, 10.0, clamp=True, lower_limit=lower, upper_limit=upper) |
| 473 | + self.assertAlmostEqual(camera.pitch, (lower + upper) * 0.5, places=6) |
| 474 | + |
| 475 | + |
| 476 | +class TestCameraControllerLerpAngle(unittest.TestCase): |
| 477 | + """Tests for shortest-path angle interpolation used during smoothing.""" |
| 478 | + |
| 479 | + def setUp(self): |
| 480 | + self.camera: Camera = Camera() |
| 481 | + self.controller: CameraController = CameraController(self.camera) |
496 | 482 |
|
497 | 483 | def test_lerp_angle_shortest_path_over_wrap(self): |
498 | | - """Smoothing must interpolate across the ±pi seam without a full spin.""" |
499 | | - ctrl = CameraController(Camera()) |
500 | | - result = ctrl._lerp_angle(3 * math.pi / 4, -3 * math.pi / 4, 0.5) |
501 | | - self.assertAlmostEqual(result, math.pi, places=9) |
| 484 | + """Interpolation should take the short arc across the ±π seam.""" |
| 485 | + a = math.pi - 0.1 |
| 486 | + b = -math.pi + 0.1 |
| 487 | + mid = self.controller._lerp_angle(a, b, 0.5) |
| 488 | + self.assertAlmostEqual(mid, math.pi, places=5) |
| 489 | + self.assertGreater(mid, a) |
502 | 490 |
|
503 | 491 |
|
504 | 492 | class TestCameraModeEnum(unittest.TestCase): |
|
0 commit comments