gardesk/garcalc / 1facd9f

Browse files

add 2D plotting engine with Cairo rendering

Authored by espadonne
SHA
1facd9fa2a4540f8a0a641bf5451dc7993d39fdc
Parents
156d2f0
Tree
b974457

3 changed files

StatusFile+-
M garcalc-graph/Cargo.toml 1 0
M garcalc-graph/src/lib.rs 2 2
M garcalc-graph/src/plot2d.rs 544 7
garcalc-graph/Cargo.tomlmodified
@@ -10,3 +10,4 @@ description = "2D/3D graphing engine for garcalc"
1010
 garcalc-cas = { workspace = true }
1111
 serde = { workspace = true }
1212
 thiserror = { workspace = true }
13
+cairo-rs = { workspace = true }
garcalc-graph/src/lib.rsmodified
@@ -2,12 +2,12 @@
22
 //!
33
 //! Provides function plotting, parametric curves, implicit curves,
44
 //! and 3D surface visualization.
5
-//!
6
-//! This is a stub for Sprint 3-4 implementation.
75
 
86
 pub mod plot2d;
97
 pub mod plot3d;
108
 
9
+pub use plot2d::{Graph2D, PlotConfig, CURVE_COLORS};
10
+
1111
 use garcalc_cas::Expr;
1212
 use serde::{Deserialize, Serialize};
1313
 
garcalc-graph/src/plot2d.rsmodified
@@ -1,20 +1,557 @@
1
-//! 2D plotting (Sprint 3)
1
+//! 2D plotting engine
2
+//!
3
+//! Provides function plotting with:
4
+//! - Explicit functions: y = f(x)
5
+//! - Parametric curves: (x(t), y(t))
6
+//! - Coordinate system with axes and grid
7
+//! - Pan/zoom interaction
8
+//! - Trace mode
29
 
3
-use crate::Viewport2D;
10
+use cairo::Context;
11
+use garcalc_cas::{Evaluator, Expr, Symbol};
412
 
5
-/// 2D graph state
13
+use crate::{Color, LineStyle, Plottable, Viewport2D};
14
+
15
+/// Configuration for the plot appearance
16
+#[derive(Debug, Clone)]
17
+pub struct PlotConfig {
18
+    /// Background color
19
+    pub background: Color,
20
+    /// Axis color
21
+    pub axis_color: Color,
22
+    /// Grid color
23
+    pub grid_color: Color,
24
+    /// Tick label color
25
+    pub label_color: Color,
26
+    /// Whether to show grid
27
+    pub show_grid: bool,
28
+    /// Whether to show axis labels
29
+    pub show_labels: bool,
30
+    /// Line width for curves
31
+    pub curve_width: f64,
32
+    /// Line width for axes
33
+    pub axis_width: f64,
34
+    /// Line width for grid
35
+    pub grid_width: f64,
36
+    /// Number of samples per pixel for function plotting
37
+    pub samples_per_pixel: f64,
38
+}
39
+
40
+impl Default for PlotConfig {
41
+    fn default() -> Self {
42
+        Self {
43
+            background: Color { r: 30, g: 30, b: 46, a: 255 },
44
+            axis_color: Color { r: 166, g: 173, b: 200, a: 255 },
45
+            grid_color: Color { r: 69, g: 71, b: 90, a: 255 },
46
+            label_color: Color { r: 166, g: 173, b: 200, a: 255 },
47
+            show_grid: true,
48
+            show_labels: true,
49
+            curve_width: 2.0,
50
+            axis_width: 1.5,
51
+            grid_width: 0.5,
52
+            samples_per_pixel: 2.0,
53
+        }
54
+    }
55
+}
56
+
57
+/// Default curve colors (catppuccin palette)
58
+pub const CURVE_COLORS: [Color; 6] = [
59
+    Color { r: 137, g: 180, b: 250, a: 255 }, // blue
60
+    Color { r: 166, g: 227, b: 161, a: 255 }, // green
61
+    Color { r: 249, g: 226, b: 175, a: 255 }, // yellow
62
+    Color { r: 243, g: 139, b: 168, a: 255 }, // red
63
+    Color { r: 203, g: 166, b: 247, a: 255 }, // mauve
64
+    Color { r: 148, g: 226, b: 213, a: 255 }, // teal
65
+];
66
+
67
+/// 2D graph state and renderer
668
 pub struct Graph2D {
69
+    /// Current viewport
770
     pub viewport: Viewport2D,
8
-    pub grid_enabled: bool,
9
-    pub axis_labels: bool,
71
+    /// Plot configuration
72
+    pub config: PlotConfig,
73
+    /// Functions to plot
74
+    pub functions: Vec<Plottable>,
75
+    /// Trace mode: show cursor position
76
+    pub trace_enabled: bool,
77
+    /// Current trace position (screen coords)
78
+    pub trace_pos: Option<(f64, f64)>,
1079
 }
1180
 
1281
 impl Default for Graph2D {
1382
     fn default() -> Self {
1483
         Self {
1584
             viewport: Viewport2D::default(),
16
-            grid_enabled: true,
17
-            axis_labels: true,
85
+            config: PlotConfig::default(),
86
+            functions: Vec::new(),
87
+            trace_enabled: false,
88
+            trace_pos: None,
89
+        }
90
+    }
91
+}
92
+
93
+impl Graph2D {
94
+    /// Create a new graph with default settings
95
+    pub fn new() -> Self {
96
+        Self::default()
97
+    }
98
+
99
+    /// Set the viewport
100
+    pub fn set_viewport(&mut self, viewport: Viewport2D) {
101
+        self.viewport = viewport;
102
+    }
103
+
104
+    /// Add a function to plot
105
+    pub fn add_function(&mut self, func: Plottable) {
106
+        self.functions.push(func);
107
+    }
108
+
109
+    /// Add an explicit function y = f(x) with auto color
110
+    pub fn add_explicit(&mut self, expr: Expr) {
111
+        let color = CURVE_COLORS[self.functions.len() % CURVE_COLORS.len()];
112
+        self.functions.push(Plottable::Explicit2D {
113
+            expr,
114
+            x_var: "x".to_string(),
115
+            color,
116
+            style: LineStyle::Solid,
117
+        });
118
+    }
119
+
120
+    /// Clear all functions
121
+    pub fn clear_functions(&mut self) {
122
+        self.functions.clear();
123
+    }
124
+
125
+    /// Pan the viewport by a screen delta
126
+    pub fn pan(&mut self, dx: f64, dy: f64, width: u32, height: u32) {
127
+        let x_range = self.viewport.x_max - self.viewport.x_min;
128
+        let y_range = self.viewport.y_max - self.viewport.y_min;
129
+
130
+        let math_dx = -dx * x_range / width as f64;
131
+        let math_dy = dy * y_range / height as f64;
132
+
133
+        self.viewport.x_min += math_dx;
134
+        self.viewport.x_max += math_dx;
135
+        self.viewport.y_min += math_dy;
136
+        self.viewport.y_max += math_dy;
137
+    }
138
+
139
+    /// Zoom by a factor centered at screen position
140
+    pub fn zoom(&mut self, factor: f64, cx: f64, cy: f64, width: u32, height: u32) {
141
+        // Convert screen position to math coordinates
142
+        let (mx, my) = self.screen_to_math(cx, cy, width, height);
143
+
144
+        // Calculate new ranges
145
+        let x_range = self.viewport.x_max - self.viewport.x_min;
146
+        let y_range = self.viewport.y_max - self.viewport.y_min;
147
+        let new_x_range = x_range / factor;
148
+        let new_y_range = y_range / factor;
149
+
150
+        // Calculate relative position of zoom center
151
+        let rx = (mx - self.viewport.x_min) / x_range;
152
+        let ry = (my - self.viewport.y_min) / y_range;
153
+
154
+        // Set new viewport centered on zoom point
155
+        self.viewport.x_min = mx - rx * new_x_range;
156
+        self.viewport.x_max = mx + (1.0 - rx) * new_x_range;
157
+        self.viewport.y_min = my - ry * new_y_range;
158
+        self.viewport.y_max = my + (1.0 - ry) * new_y_range;
159
+    }
160
+
161
+    /// Reset viewport to default
162
+    pub fn reset_viewport(&mut self) {
163
+        self.viewport = Viewport2D::default();
164
+    }
165
+
166
+    /// Convert screen coordinates to math coordinates
167
+    pub fn screen_to_math(&self, sx: f64, sy: f64, width: u32, height: u32) -> (f64, f64) {
168
+        let x_range = self.viewport.x_max - self.viewport.x_min;
169
+        let y_range = self.viewport.y_max - self.viewport.y_min;
170
+
171
+        let mx = self.viewport.x_min + (sx / width as f64) * x_range;
172
+        let my = self.viewport.y_max - (sy / height as f64) * y_range;
173
+
174
+        (mx, my)
175
+    }
176
+
177
+    /// Convert math coordinates to screen coordinates
178
+    pub fn math_to_screen(&self, mx: f64, my: f64, width: u32, height: u32) -> (f64, f64) {
179
+        let x_range = self.viewport.x_max - self.viewport.x_min;
180
+        let y_range = self.viewport.y_max - self.viewport.y_min;
181
+
182
+        let sx = ((mx - self.viewport.x_min) / x_range) * width as f64;
183
+        let sy = ((self.viewport.y_max - my) / y_range) * height as f64;
184
+
185
+        (sx, sy)
186
+    }
187
+
188
+    /// Update trace position
189
+    pub fn set_trace_pos(&mut self, x: f64, y: f64) {
190
+        self.trace_pos = Some((x, y));
191
+    }
192
+
193
+    /// Render the graph to a Cairo context
194
+    pub fn render(&self, ctx: &Context, width: u32, height: u32) {
195
+        let w = width as f64;
196
+        let h = height as f64;
197
+
198
+        // Background
199
+        set_color(ctx, self.config.background);
200
+        ctx.rectangle(0.0, 0.0, w, h);
201
+        let _ = ctx.fill();
202
+
203
+        // Grid
204
+        if self.config.show_grid {
205
+            self.draw_grid(ctx, width, height);
206
+        }
207
+
208
+        // Axes
209
+        self.draw_axes(ctx, width, height);
210
+
211
+        // Functions
212
+        for func in &self.functions {
213
+            self.draw_function(ctx, func, width, height);
214
+        }
215
+
216
+        // Trace
217
+        if self.trace_enabled {
218
+            if let Some((sx, sy)) = self.trace_pos {
219
+                self.draw_trace(ctx, sx, sy, width, height);
220
+            }
221
+        }
222
+    }
223
+
224
+    fn draw_grid(&self, ctx: &Context, width: u32, height: u32) {
225
+        set_color(ctx, self.config.grid_color);
226
+        ctx.set_line_width(self.config.grid_width);
227
+
228
+        let x_range = self.viewport.x_max - self.viewport.x_min;
229
+        let y_range = self.viewport.y_max - self.viewport.y_min;
230
+
231
+        // Calculate nice grid spacing
232
+        let x_step = nice_step(x_range / 10.0);
233
+        let y_step = nice_step(y_range / 10.0);
234
+
235
+        // Vertical grid lines
236
+        let x_start = (self.viewport.x_min / x_step).floor() * x_step;
237
+        let mut x = x_start;
238
+        while x <= self.viewport.x_max {
239
+            let (sx, _) = self.math_to_screen(x, 0.0, width, height);
240
+            ctx.move_to(sx, 0.0);
241
+            ctx.line_to(sx, height as f64);
242
+            x += x_step;
243
+        }
244
+
245
+        // Horizontal grid lines
246
+        let y_start = (self.viewport.y_min / y_step).floor() * y_step;
247
+        let mut y = y_start;
248
+        while y <= self.viewport.y_max {
249
+            let (_, sy) = self.math_to_screen(0.0, y, width, height);
250
+            ctx.move_to(0.0, sy);
251
+            ctx.line_to(width as f64, sy);
252
+            y += y_step;
253
+        }
254
+
255
+        let _ = ctx.stroke();
256
+    }
257
+
258
+    fn draw_axes(&self, ctx: &Context, width: u32, height: u32) {
259
+        set_color(ctx, self.config.axis_color);
260
+        ctx.set_line_width(self.config.axis_width);
261
+
262
+        // Y-axis (x = 0)
263
+        if self.viewport.x_min <= 0.0 && self.viewport.x_max >= 0.0 {
264
+            let (sx, _) = self.math_to_screen(0.0, 0.0, width, height);
265
+            ctx.move_to(sx, 0.0);
266
+            ctx.line_to(sx, height as f64);
267
+        }
268
+
269
+        // X-axis (y = 0)
270
+        if self.viewport.y_min <= 0.0 && self.viewport.y_max >= 0.0 {
271
+            let (_, sy) = self.math_to_screen(0.0, 0.0, width, height);
272
+            ctx.move_to(0.0, sy);
273
+            ctx.line_to(width as f64, sy);
274
+        }
275
+
276
+        let _ = ctx.stroke();
277
+
278
+        // Tick labels
279
+        if self.config.show_labels {
280
+            self.draw_tick_labels(ctx, width, height);
281
+        }
282
+    }
283
+
284
+    fn draw_tick_labels(&self, ctx: &Context, width: u32, height: u32) {
285
+        set_color(ctx, self.config.label_color);
286
+        ctx.set_font_size(11.0);
287
+
288
+        let x_range = self.viewport.x_max - self.viewport.x_min;
289
+        let y_range = self.viewport.y_max - self.viewport.y_min;
290
+
291
+        let x_step = nice_step(x_range / 10.0);
292
+        let y_step = nice_step(y_range / 10.0);
293
+
294
+        // Get y-axis position for x labels
295
+        let (_, y_axis_sy) = self.math_to_screen(0.0, 0.0, width, height);
296
+        let label_y = y_axis_sy.clamp(12.0, height as f64 - 4.0);
297
+
298
+        // X-axis labels
299
+        let x_start = (self.viewport.x_min / x_step).ceil() * x_step;
300
+        let mut x = x_start;
301
+        while x <= self.viewport.x_max {
302
+            if x.abs() > x_step * 0.1 {
303
+                let (sx, _) = self.math_to_screen(x, 0.0, width, height);
304
+                let label = format_number(x);
305
+                ctx.move_to(sx - 10.0, label_y + 12.0);
306
+                let _ = ctx.show_text(&label);
307
+            }
308
+            x += x_step;
309
+        }
310
+
311
+        // Get x-axis position for y labels
312
+        let (x_axis_sx, _) = self.math_to_screen(0.0, 0.0, width, height);
313
+        let label_x = x_axis_sx.clamp(4.0, width as f64 - 40.0);
314
+
315
+        // Y-axis labels
316
+        let y_start = (self.viewport.y_min / y_step).ceil() * y_step;
317
+        let mut y = y_start;
318
+        while y <= self.viewport.y_max {
319
+            if y.abs() > y_step * 0.1 {
320
+                let (_, sy) = self.math_to_screen(0.0, y, width, height);
321
+                let label = format_number(y);
322
+                ctx.move_to(label_x + 4.0, sy + 4.0);
323
+                let _ = ctx.show_text(&label);
324
+            }
325
+            y += y_step;
326
+        }
327
+    }
328
+
329
+    fn draw_function(&self, ctx: &Context, func: &Plottable, width: u32, height: u32) {
330
+        match func {
331
+            Plottable::Explicit2D { expr, x_var, color, style } => {
332
+                self.draw_explicit(ctx, expr, x_var, *color, *style, width, height);
333
+            }
334
+            Plottable::Parametric2D { x_expr, y_expr, t_var, t_range, color } => {
335
+                self.draw_parametric(ctx, x_expr, y_expr, t_var, *t_range, *color, width, height);
336
+            }
337
+            _ => {}
338
+        }
339
+    }
340
+
341
+    fn draw_explicit(
342
+        &self,
343
+        ctx: &Context,
344
+        expr: &Expr,
345
+        x_var: &str,
346
+        color: Color,
347
+        style: LineStyle,
348
+        width: u32,
349
+        height: u32,
350
+    ) {
351
+        set_color(ctx, color);
352
+        ctx.set_line_width(self.config.curve_width);
353
+
354
+        match style {
355
+            LineStyle::Solid => ctx.set_dash(&[], 0.0),
356
+            LineStyle::Dashed => ctx.set_dash(&[6.0, 4.0], 0.0),
357
+            LineStyle::Dotted => ctx.set_dash(&[2.0, 2.0], 0.0),
358
+        }
359
+
360
+        let mut evaluator = Evaluator::new();
361
+        let _var = Symbol::from(x_var);
362
+
363
+        // Sample the function
364
+        let num_samples = (width as f64 * self.config.samples_per_pixel) as usize;
365
+        let x_range = self.viewport.x_max - self.viewport.x_min;
366
+        let dx = x_range / num_samples as f64;
367
+
368
+        let mut first = true;
369
+        let mut last_valid = false;
370
+
371
+        for i in 0..=num_samples {
372
+            let math_x = self.viewport.x_min + i as f64 * dx;
373
+            evaluator.set_var(x_var, Expr::Float(math_x));
374
+
375
+            if let Ok(result) = evaluator.eval(expr) {
376
+                if let Ok(math_y) = expr_to_f64(&result) {
377
+                    if math_y.is_finite() && math_y >= self.viewport.y_min - x_range
378
+                        && math_y <= self.viewport.y_max + x_range
379
+                    {
380
+                        let (sx, sy) = self.math_to_screen(math_x, math_y, width, height);
381
+
382
+                        if first || !last_valid {
383
+                            ctx.move_to(sx, sy);
384
+                            first = false;
385
+                        } else {
386
+                            ctx.line_to(sx, sy);
387
+                        }
388
+                        last_valid = true;
389
+                        continue;
390
+                    }
391
+                }
392
+            }
393
+            // Invalid point - break the line
394
+            if last_valid {
395
+                let _ = ctx.stroke();
396
+            }
397
+            last_valid = false;
398
+        }
399
+
400
+        let _ = ctx.stroke();
401
+        ctx.set_dash(&[], 0.0);
402
+    }
403
+
404
+    fn draw_parametric(
405
+        &self,
406
+        ctx: &Context,
407
+        x_expr: &Expr,
408
+        y_expr: &Expr,
409
+        t_var: &str,
410
+        t_range: (f64, f64),
411
+        color: Color,
412
+        width: u32,
413
+        height: u32,
414
+    ) {
415
+        set_color(ctx, color);
416
+        ctx.set_line_width(self.config.curve_width);
417
+
418
+        let mut evaluator = Evaluator::new();
419
+
420
+        let num_samples = (width as f64 * self.config.samples_per_pixel) as usize;
421
+        let t_span = t_range.1 - t_range.0;
422
+        let dt = t_span / num_samples as f64;
423
+
424
+        let mut first = true;
425
+
426
+        for i in 0..=num_samples {
427
+            let t = t_range.0 + i as f64 * dt;
428
+            evaluator.set_var(t_var, Expr::Float(t));
429
+
430
+            let x_result = evaluator.eval(x_expr);
431
+            let y_result = evaluator.eval(y_expr);
432
+
433
+            if let (Ok(x_val), Ok(y_val)) = (x_result, y_result) {
434
+                if let (Ok(math_x), Ok(math_y)) = (expr_to_f64(&x_val), expr_to_f64(&y_val)) {
435
+                    if math_x.is_finite() && math_y.is_finite() {
436
+                        let (sx, sy) = self.math_to_screen(math_x, math_y, width, height);
437
+
438
+                        if first {
439
+                            ctx.move_to(sx, sy);
440
+                            first = false;
441
+                        } else {
442
+                            ctx.line_to(sx, sy);
443
+                        }
444
+                    }
445
+                }
446
+            }
18447
         }
448
+
449
+        let _ = ctx.stroke();
450
+    }
451
+
452
+    fn draw_trace(&self, ctx: &Context, sx: f64, sy: f64, width: u32, height: u32) {
453
+        let (mx, my) = self.screen_to_math(sx, sy, width, height);
454
+
455
+        // Crosshair
456
+        set_color(ctx, self.config.axis_color);
457
+        ctx.set_line_width(0.5);
458
+        ctx.set_dash(&[4.0, 4.0], 0.0);
459
+
460
+        ctx.move_to(sx, 0.0);
461
+        ctx.line_to(sx, height as f64);
462
+        ctx.move_to(0.0, sy);
463
+        ctx.line_to(width as f64, sy);
464
+        let _ = ctx.stroke();
465
+        ctx.set_dash(&[], 0.0);
466
+
467
+        // Coordinate display
468
+        set_color(ctx, Color { r: 30, g: 30, b: 46, a: 200 });
469
+        let label = format!("({:.4}, {:.4})", mx, my);
470
+        let label_w = label.len() as f64 * 7.0 + 8.0;
471
+        let label_h = 18.0;
472
+        let lx = (sx + 10.0).min(width as f64 - label_w - 4.0);
473
+        let ly = (sy - 24.0).max(4.0);
474
+
475
+        ctx.rectangle(lx, ly, label_w, label_h);
476
+        let _ = ctx.fill();
477
+
478
+        set_color(ctx, self.config.label_color);
479
+        ctx.set_font_size(12.0);
480
+        ctx.move_to(lx + 4.0, ly + 13.0);
481
+        let _ = ctx.show_text(&label);
482
+    }
483
+}
484
+
485
+/// Set Cairo source color
486
+fn set_color(ctx: &Context, color: Color) {
487
+    ctx.set_source_rgba(
488
+        color.r as f64 / 255.0,
489
+        color.g as f64 / 255.0,
490
+        color.b as f64 / 255.0,
491
+        color.a as f64 / 255.0,
492
+    );
493
+}
494
+
495
+/// Calculate a nice step size for grid/ticks
496
+fn nice_step(rough: f64) -> f64 {
497
+    let exp = rough.abs().log10().floor();
498
+    let frac = rough / 10f64.powf(exp);
499
+
500
+    let nice = if frac < 1.5 {
501
+        1.0
502
+    } else if frac < 3.0 {
503
+        2.0
504
+    } else if frac < 7.0 {
505
+        5.0
506
+    } else {
507
+        10.0
508
+    };
509
+
510
+    nice * 10f64.powf(exp)
511
+}
512
+
513
+/// Format a number for axis labels
514
+fn format_number(x: f64) -> String {
515
+    if x == 0.0 {
516
+        return "0".to_string();
517
+    }
518
+    let abs = x.abs();
519
+    if abs >= 1000.0 || abs < 0.01 {
520
+        format!("{:.1e}", x)
521
+    } else if abs >= 1.0 {
522
+        format!("{:.1}", x)
523
+    } else {
524
+        format!("{:.2}", x)
525
+    }
526
+}
527
+
528
+/// Convert an Expr to f64
529
+fn expr_to_f64(expr: &Expr) -> Result<f64, ()> {
530
+    match expr {
531
+        Expr::Integer(n) => Ok(*n as f64),
532
+        Expr::Float(x) => Ok(*x),
533
+        Expr::Rational(r) => Ok(r.to_f64()),
534
+        _ => Err(()),
535
+    }
536
+}
537
+
538
+#[cfg(test)]
539
+mod tests {
540
+    use super::*;
541
+
542
+    #[test]
543
+    fn test_coordinate_transform() {
544
+        let graph = Graph2D::new();
545
+        let (mx, my) = graph.screen_to_math(250.0, 250.0, 500, 500);
546
+        assert!((mx - 0.0).abs() < 0.01);
547
+        assert!((my - 0.0).abs() < 0.01);
548
+    }
549
+
550
+    #[test]
551
+    fn test_nice_step() {
552
+        assert_eq!(nice_step(0.03), 0.02);
553
+        assert_eq!(nice_step(0.3), 0.2);
554
+        assert_eq!(nice_step(3.0), 2.0);
555
+        assert_eq!(nice_step(30.0), 20.0);
19556
     }
20557
 }