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
This commit is contained in:
2026-03-19 03:33:48 -07:00
parent 14d622f0d6
commit 0eb5f1d5ff
3 changed files with 118 additions and 21 deletions

View File

@@ -23,6 +23,7 @@ class CameraMode(Enum):
OMNI = auto() OMNI = auto()
FLOATING = auto() FLOATING = auto()
BOUNCE = auto() BOUNCE = auto()
RADIAL = auto() # Polar coordinates (r, theta) for radial scanning
@dataclass @dataclass
@@ -92,14 +93,17 @@ class Camera:
""" """
return max(1, int(self.canvas_height / self.zoom)) 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. """Get the current viewport bounds.
Args:
viewport_height: Optional viewport height to use instead of camera's viewport_height
Returns: Returns:
CameraViewport with position and size (clamped to canvas bounds) CameraViewport with position and size (clamped to canvas bounds)
""" """
vw = self.viewport_width 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_x = max(0, min(self.x, self.canvas_width - vw))
clamped_y = max(0, min(self.y, self.canvas_height - vh)) clamped_y = max(0, min(self.y, self.canvas_height - vh))
@@ -111,6 +115,13 @@ class Camera:
height=vh, height=vh,
) )
return CameraViewport(
x=clamped_x,
y=clamped_y,
width=vw,
height=vh,
)
def set_zoom(self, zoom: float) -> None: def set_zoom(self, zoom: float) -> None:
"""Set the zoom factor. """Set the zoom factor.
@@ -143,6 +154,8 @@ class Camera:
self._update_floating(dt) self._update_floating(dt)
elif self.mode == CameraMode.BOUNCE: elif self.mode == CameraMode.BOUNCE:
self._update_bounce(dt) self._update_bounce(dt)
elif self.mode == CameraMode.RADIAL:
self._update_radial(dt)
# Bounce mode handles its own bounds checking # Bounce mode handles its own bounds checking
if self.mode != CameraMode.BOUNCE: if self.mode != CameraMode.BOUNCE:
@@ -223,12 +236,85 @@ class Camera:
self.y = max_y self.y = max_y
self._bounce_dy = -1 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: def reset(self) -> None:
"""Reset camera position.""" """Reset camera position and state."""
self.x = 0 self.x = 0
self.y = 0 self.y = 0
self._time = 0.0 self._time = 0.0
self.zoom = 1.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: def set_canvas_size(self, width: int, height: int) -> None:
"""Set the canvas size and clamp position if needed. """Set the canvas size and clamp position if needed.
@@ -263,7 +349,7 @@ class Camera:
return buffer return buffer
# Get current viewport bounds (clamped to canvas size) # 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 # Use provided viewport_height if given, otherwise use camera's viewport
vh = viewport_height if viewport_height is not None else viewport.height 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 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 @classmethod
def custom(cls, update_fn: Callable[["Camera", float], None]) -> "Camera": def custom(cls, update_fn: Callable[["Camera", float], None]) -> "Camera":
"""Create a camera with custom update function.""" """Create a camera with custom update function."""

View File

@@ -3,7 +3,6 @@ ANSI terminal display backend.
""" """
import os import os
import time
class TerminalDisplay: class TerminalDisplay:
@@ -89,16 +88,8 @@ class TerminalDisplay:
from engine.display import get_monitor, render_border from engine.display import get_monitor, render_border
t0 = time.perf_counter() # Note: Frame rate limiting is handled by the caller (e.g., FrameTimer).
# This display renders every frame it receives.
# 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
# Get metrics for border display # Get metrics for border display
fps = 0.0 fps = 0.0
@@ -117,15 +108,9 @@ class TerminalDisplay:
buffer = render_border(buffer, self.width, self.height, fps, frame_time) buffer = render_border(buffer, self.width, self.height, fps, frame_time)
# Write buffer with cursor home + erase down to avoid flicker # 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) output = "\033[H\033[J" + "".join(buffer)
sys.stdout.buffer.write(output.encode()) sys.stdout.buffer.write(output.encode())
sys.stdout.flush() 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: def clear(self) -> None:
from engine.terminal import CLR from engine.terminal import CLR

View File

@@ -100,6 +100,11 @@ class EffectContext:
"""Get a state value from the context.""" """Get a state value from the context."""
return self._state.get(key, default) 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 @dataclass
class EffectConfig: class EffectConfig: