hotwire/
lib.rs

1//! # hotwire
2//!
3//! A Rust library for drawing drooping patch-cable wires using SDL3.
4//!
5//! ## Example
6//!
7//! ```rust,no_run
8//! use hotwire::{WireLine, WireRenderer, WireParams};
9//! use sdl3::pixels::Color;
10//!
11//! let sdl = sdl3::init().unwrap();
12//! let video = sdl.video().unwrap();
13//! let window = video.window("Wire Lines", 800, 600)
14//!     .position_centered()
15//!     .build()
16//!     .unwrap();
17//! let canvas = window.into_canvas();
18//!
19//! let mut renderer = WireRenderer::new(canvas);
20//! let wire_line = WireLine::new(100.0, 300.0, 700.0, 300.0, WireParams::default());
21//!
22//! renderer.render(&wire_line, 0.0);
23//! renderer.present();
24//! ```
25
26use noise::{NoiseFn, Perlin};
27use rand::Rng;
28use sdl3::pixels::Color;
29use sdl3::render::{Canvas, FPoint};
30use sdl3::video::Window;
31
32/// Parameters controlling the wire appearance
33#[derive(Debug, Clone, Copy)]
34pub struct WireParams {
35    /// Amount of sag/droop in the wire (in pixels)
36    pub sag: f32,
37    /// Thickness of the wire
38    pub thickness: f32,
39    /// Speed of subtle swaying animation
40    pub sway_speed: f32,
41    /// Amplitude of swaying motion
42    pub sway_amount: f32,
43    /// Density of wire segments
44    pub segment_density: f32,
45    /// Base color of the wire
46    pub color: Color,
47}
48
49impl Default for WireParams {
50    fn default() -> Self {
51        Self {
52            sag: 50.0,
53            thickness: 3.0,
54            sway_speed: 1.0,
55            sway_amount: 5.0,
56            segment_density: 2.0,
57            color: Color::RGB(180, 180, 180),
58        }
59    }
60}
61
62/// Represents a wire that will be rendered with drooping effect
63#[derive(Debug, Clone, Copy)]
64pub struct WireLine {
65    pub start: FPoint,
66    pub end: FPoint,
67    pub params: WireParams,
68}
69
70impl WireLine {
71    /// Create a new wire line from coordinates and parameters
72    pub fn new(x1: f32, y1: f32, x2: f32, y2: f32, params: WireParams) -> Self {
73        Self {
74            start: FPoint::new(x1, y1),
75            end: FPoint::new(x2, y2),
76            params,
77        }
78    }
79
80    /// Get the horizontal distance of the line
81    pub fn horizontal_distance(&self) -> f32 {
82        (self.end.x - self.start.x).abs()
83    }
84
85    /// Get the vertical distance of the line
86    pub fn vertical_distance(&self) -> f32 {
87        self.end.y - self.start.y
88    }
89
90    /// Calculate the y position at parameter t (0 to 1) along the catenary curve
91    fn catenary_y(&self, t: f32) -> f32 {
92        let dy = self.end.y - self.start.y;
93
94        // Linear interpolation for the straight-line component
95        let linear_y = self.start.y + t * dy;
96
97        // Parabolic droop (simpler than full catenary, looks good enough)
98        // Maximum droop at t=0.5
99        let droop = 4.0 * t * (1.0 - t) * self.params.sag;
100
101        linear_y + droop
102    }
103}
104
105/// Renderer for wire lines using SDL3
106pub struct WireRenderer {
107    canvas: Canvas<Window>,
108    noise: Perlin,
109}
110
111impl WireRenderer {
112    /// Create a new wire renderer with the given canvas
113    pub fn new(canvas: Canvas<Window>) -> Self {
114        Self {
115            canvas,
116            noise: Perlin::new(rand::rng().random()),
117        }
118    }
119
120    /// Get a mutable reference to the canvas
121    pub fn canvas_mut(&mut self) -> &mut Canvas<Window> {
122        &mut self.canvas
123    }
124
125    /// Present the rendered frame
126    pub fn present(&mut self) {
127        self.canvas.present();
128    }
129
130    /// Render a wire line at the given time
131    pub fn render(&mut self, line: &WireLine, time: f32) -> Result<(), String> {
132        let horizontal_dist = line.horizontal_distance();
133        let num_segments = (horizontal_dist * line.params.segment_density) as usize;
134
135        if num_segments == 0 {
136            return Ok(());
137        }
138
139        let dx = line.end.x - line.start.x;
140
141        for i in 0..=num_segments {
142            let t = i as f32 / num_segments as f32;
143
144            // Base position along the catenary curve
145            let x = line.start.x + t * dx;
146            let base_y = line.catenary_y(t);
147
148            // Add subtle swaying using noise
149            let sway_offset = self.noise.get([
150                x as f64 * 0.02,
151                (time * line.params.sway_speed) as f64,
152                0.0,
153            ]) as f32 * line.params.sway_amount;
154
155            let y = base_y + sway_offset;
156
157            // Draw the wire segment with thickness
158            for thickness_offset in 0..line.params.thickness as i32 {
159                let offset = thickness_offset as f32 - line.params.thickness * 0.5;
160
161                // Draw vertically offset points for thickness
162                let py = y + offset;
163
164                // Slightly vary color for cable texture
165                let color_var = (self.noise.get([
166                    x as f64 * 0.1,
167                    py as f64 * 0.1,
168                    1.0,
169                ]) * 20.0) as i32;
170
171                let r = (line.params.color.r as i32 + color_var).clamp(0, 255) as u8;
172                let g = (line.params.color.g as i32 + color_var).clamp(0, 255) as u8;
173                let b = (line.params.color.b as i32 + color_var).clamp(0, 255) as u8;
174
175                self.canvas.set_draw_color(Color::RGB(r, g, b));
176                self.canvas.draw_point(FPoint::new(x, py)).map_err(|e| e.to_string())?;
177            }
178        }
179
180        Ok(())
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn test_wire_line_creation() {
190        let line = WireLine::new(0.0, 0.0, 100.0, 0.0, WireParams::default());
191        assert_eq!(line.start.x, 0.0);
192        assert_eq!(line.start.y, 0.0);
193        assert_eq!(line.end.x, 100.0);
194        assert_eq!(line.end.y, 0.0);
195    }
196
197    #[test]
198    fn test_wire_line_horizontal_distance() {
199        let line = WireLine::new(0.0, 0.0, 100.0, 0.0, WireParams::default());
200        assert_eq!(line.horizontal_distance(), 100.0);
201    }
202
203    #[test]
204    fn test_catenary_y_midpoint() {
205        let line = WireLine::new(0.0, 0.0, 100.0, 0.0, WireParams {
206            sag: 50.0,
207            ..WireParams::default()
208        });
209
210        // At t=0.5, we should have maximum droop
211        let y_mid = line.catenary_y(0.5);
212        assert!(y_mid > 0.0, "Wire should droop downward at midpoint");
213    }
214
215    #[test]
216    fn test_catenary_y_endpoints() {
217        let line = WireLine::new(0.0, 100.0, 100.0, 200.0, WireParams::default());
218
219        // At endpoints, should match start/end y
220        assert_eq!(line.catenary_y(0.0), 100.0);
221        assert_eq!(line.catenary_y(1.0), 200.0);
222    }
223}