Interactive orbit camera with WASD FPS-style movement.
| 28 | |
| 29 | |
| 30 | class OrbitCamera: |
| 31 | """Interactive orbit camera with WASD FPS-style movement.""" |
| 32 | |
| 33 | def __init__( |
| 34 | self, |
| 35 | eye: np.ndarray, |
| 36 | look_at: np.ndarray, |
| 37 | up: np.ndarray = None, |
| 38 | fov_deg: float = 60.0, |
| 39 | ): |
| 40 | self.eye = np.asarray(eye, dtype=np.float32).copy() |
| 41 | self.look_at = np.asarray(look_at, dtype=np.float32).copy() |
| 42 | self.up = np.asarray( |
| 43 | up if up is not None else [0, -1, 0], dtype=np.float32 |
| 44 | ).copy() |
| 45 | self.fov_deg = fov_deg |
| 46 | |
| 47 | # ----- Derived vectors ----- |
| 48 | |
| 49 | @property |
| 50 | def forward(self) -> np.ndarray: |
| 51 | return _normalize(self.look_at - self.eye) |
| 52 | |
| 53 | @property |
| 54 | def right(self) -> np.ndarray: |
| 55 | return _normalize(np.cross(self.up, self.forward)) |
| 56 | |
| 57 | @property |
| 58 | def camera_up(self) -> np.ndarray: |
| 59 | """Actual up vector (orthogonal to forward and right).""" |
| 60 | return _normalize(np.cross(self.right, self.forward)) |
| 61 | |
| 62 | @property |
| 63 | def distance(self) -> float: |
| 64 | return float(np.linalg.norm(self.look_at - self.eye)) |
| 65 | |
| 66 | # ----- Mouse interactions ----- |
| 67 | |
| 68 | def orbit(self, dx: float, dy: float, sensitivity: float = 0.005): |
| 69 | """Rotate around look_at point. dx = horizontal, dy = vertical.""" |
| 70 | offset = self.eye - self.look_at |
| 71 | |
| 72 | # Horizontal rotation (around world up) |
| 73 | angle_h = -dx * sensitivity |
| 74 | cos_h, sin_h = np.cos(angle_h), np.sin(angle_h) |
| 75 | up = _normalize(self.up) |
| 76 | # Rodrigues rotation around up axis |
| 77 | offset = ( |
| 78 | offset * cos_h |
| 79 | + np.cross(up, offset) * sin_h |
| 80 | + up * np.dot(up, offset) * (1 - cos_h) |
| 81 | ) |
| 82 | |
| 83 | # Vertical rotation (around right axis) |
| 84 | angle_v = -dy * sensitivity |
| 85 | right = _normalize(np.cross(up, _normalize(offset))) |
| 86 | cos_v, sin_v = np.cos(angle_v), np.sin(angle_v) |
| 87 | offset = ( |
no outgoing calls
no test coverage detected