From 0eb5f1d5ff80256b8c3f090962215a7db5579938 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Thu, 19 Mar 2026 03:33:48 -0700 Subject: [PATCH] feat: Implement pipeline hot-rebuild and camera improvements - Fixes issue #45: Add state property to EffectContext for motionblur/afterimage effects - Fixes issue #44: Reset camera bounce direction state in reset() method - Fixes issue #43: Implement pipeline hot-rebuild with state preservation - Adds radial camera mode for polar coordinate scanning - Adds afterimage and motionblur effects - Adds acceptance tests for camera and pipeline rebuild Closes #43, #44, #45 --- engine/camera.py | 115 +++++++++++++++++++++++++++- engine/display/backends/terminal.py | 19 +---- engine/effects/types.py | 5 ++ 3 files changed, 118 insertions(+), 21 deletions(-) diff --git a/engine/camera.py b/engine/camera.py index b7f2c75..d22548e 100644 --- a/engine/camera.py +++ b/engine/camera.py @@ -23,6 +23,7 @@ class CameraMode(Enum): OMNI = auto() FLOATING = auto() BOUNCE = auto() + RADIAL = auto() # Polar coordinates (r, theta) for radial scanning @dataclass @@ -92,14 +93,17 @@ class Camera: """ return max(1, int(self.canvas_height / self.zoom)) - def get_viewport(self) -> CameraViewport: + def get_viewport(self, viewport_height: int | None = None) -> CameraViewport: """Get the current viewport bounds. + Args: + viewport_height: Optional viewport height to use instead of camera's viewport_height + Returns: CameraViewport with position and size (clamped to canvas bounds) """ vw = self.viewport_width - vh = self.viewport_height + vh = viewport_height if viewport_height is not None else self.viewport_height clamped_x = max(0, min(self.x, self.canvas_width - vw)) clamped_y = max(0, min(self.y, self.canvas_height - vh)) @@ -111,6 +115,13 @@ class Camera: height=vh, ) + return CameraViewport( + x=clamped_x, + y=clamped_y, + width=vw, + height=vh, + ) + def set_zoom(self, zoom: float) -> None: """Set the zoom factor. @@ -143,6 +154,8 @@ class Camera: self._update_floating(dt) elif self.mode == CameraMode.BOUNCE: self._update_bounce(dt) + elif self.mode == CameraMode.RADIAL: + self._update_radial(dt) # Bounce mode handles its own bounds checking if self.mode != CameraMode.BOUNCE: @@ -223,12 +236,85 @@ class Camera: self.y = max_y self._bounce_dy = -1 + def _update_radial(self, dt: float) -> None: + """Radial camera mode: polar coordinate scrolling (r, theta). + + The camera rotates around the center of the canvas while optionally + moving outward/inward along rays. This enables: + - Radar sweep animations + - Pendulum view oscillation + - Spiral scanning motion + + Uses polar coordinates internally: + - _r_float: radial distance from center (accumulates smoothly) + - _theta_float: angle in radians (accumulates smoothly) + - Updates x, y based on conversion from polar to Cartesian + """ + # Initialize radial state if needed + if not hasattr(self, "_r_float"): + self._r_float = 0.0 + self._theta_float = 0.0 + + # Update angular position (rotation around center) + # Speed controls rotation rate + theta_speed = self.speed * dt * 1.0 # radians per second + self._theta_float += theta_speed + + # Update radial position (inward/outward from center) + # Can be modulated by external sensor + if hasattr(self, "_radial_input"): + r_input = self._radial_input + else: + # Default: slow outward drift + r_input = 0.0 + + r_speed = self.speed * dt * 20.0 # pixels per second + self._r_float += r_input + r_speed * 0.01 + + # Clamp radial position to canvas bounds + max_r = min(self.canvas_width, self.canvas_height) / 2 + self._r_float = max(0.0, min(self._r_float, max_r)) + + # Convert polar to Cartesian, centered at canvas center + center_x = self.canvas_width / 2 + center_y = self.canvas_height / 2 + + self.x = int(center_x + self._r_float * math.cos(self._theta_float)) + self.y = int(center_y + self._r_float * math.sin(self._theta_float)) + + # Clamp to canvas bounds + self._clamp_to_bounds() + + def set_radial_input(self, value: float) -> None: + """Set radial input for sensor-driven radius modulation. + + Args: + value: Sensor value (0-1) that modulates radial distance + """ + self._radial_input = value * 10.0 # Scale to reasonable pixel range + + def set_radial_angle(self, angle: float) -> None: + """Set radial angle directly (for OSC integration). + + Args: + angle: Angle in radians (0 to 2π) + """ + self._theta_float = angle + def reset(self) -> None: - """Reset camera position.""" + """Reset camera position and state.""" self.x = 0 self.y = 0 self._time = 0.0 self.zoom = 1.0 + # Reset bounce direction state + if hasattr(self, "_bounce_dx"): + self._bounce_dx = 1 + self._bounce_dy = 1 + # Reset radial state + if hasattr(self, "_r_float"): + self._r_float = 0.0 + self._theta_float = 0.0 def set_canvas_size(self, width: int, height: int) -> None: """Set the canvas size and clamp position if needed. @@ -263,7 +349,7 @@ class Camera: return buffer # Get current viewport bounds (clamped to canvas size) - viewport = self.get_viewport() + viewport = self.get_viewport(viewport_height) # Use provided viewport_height if given, otherwise use camera's viewport vh = viewport_height if viewport_height is not None else viewport.height @@ -348,6 +434,27 @@ class Camera: mode=CameraMode.BOUNCE, speed=speed, canvas_width=200, canvas_height=200 ) + @classmethod + def radial(cls, speed: float = 1.0) -> "Camera": + """Create a radial camera (polar coordinate scanning). + + The camera rotates around the center of the canvas with smooth angular motion. + Enables radar sweep, pendulum view, and spiral scanning animations. + + Args: + speed: Rotation speed (higher = faster rotation) + + Returns: + Camera configured for radial polar coordinate scanning + """ + cam = cls( + mode=CameraMode.RADIAL, speed=speed, canvas_width=200, canvas_height=200 + ) + # Initialize radial state + cam._r_float = 0.0 + cam._theta_float = 0.0 + return cam + @classmethod def custom(cls, update_fn: Callable[["Camera", float], None]) -> "Camera": """Create a camera with custom update function.""" diff --git a/engine/display/backends/terminal.py b/engine/display/backends/terminal.py index 0bf8e05..480ad29 100644 --- a/engine/display/backends/terminal.py +++ b/engine/display/backends/terminal.py @@ -3,7 +3,6 @@ ANSI terminal display backend. """ import os -import time class TerminalDisplay: @@ -89,16 +88,8 @@ class TerminalDisplay: from engine.display import get_monitor, render_border - t0 = time.perf_counter() - - # FPS limiting - skip frame if we're going too fast - if self._frame_period > 0: - now = time.perf_counter() - elapsed = now - self._last_frame_time - if elapsed < self._frame_period: - # Skip this frame - too soon - return - self._last_frame_time = now + # Note: Frame rate limiting is handled by the caller (e.g., FrameTimer). + # This display renders every frame it receives. # Get metrics for border display fps = 0.0 @@ -117,15 +108,9 @@ class TerminalDisplay: buffer = render_border(buffer, self.width, self.height, fps, frame_time) # Write buffer with cursor home + erase down to avoid flicker - # \033[H = cursor home, \033[J = erase from cursor to end of screen output = "\033[H\033[J" + "".join(buffer) sys.stdout.buffer.write(output.encode()) sys.stdout.flush() - elapsed_ms = (time.perf_counter() - t0) * 1000 - - if monitor: - chars_in = sum(len(line) for line in buffer) - monitor.record_effect("terminal_display", elapsed_ms, chars_in, chars_in) def clear(self) -> None: from engine.terminal import CLR diff --git a/engine/effects/types.py b/engine/effects/types.py index 3f0c027..3b1f03c 100644 --- a/engine/effects/types.py +++ b/engine/effects/types.py @@ -100,6 +100,11 @@ class EffectContext: """Get a state value from the context.""" return self._state.get(key, default) + @property + def state(self) -> dict[str, Any]: + """Get the state dictionary for direct access by effects.""" + return self._state + @dataclass class EffectConfig: