gardesk/garcalc / 5ef482d

Browse files

add 3D surface plotting engine with perspective projection

Authored by espadonne
SHA
5ef482dfcb6e3f0fe1b233149cd1ef01b19f2abb
Parents
af6fce7
Tree
d31bc33

2 changed files

StatusFile+-
M garcalc-graph/src/lib.rs 1 0
M garcalc-graph/src/plot3d.rs 690 3
garcalc-graph/src/lib.rsmodified
@@ -7,6 +7,7 @@ pub mod plot2d;
77
 pub mod plot3d;
88
 
99
 pub use plot2d::{Graph2D, PlotConfig, CURVE_COLORS};
10
+pub use plot3d::{Graph3D, Plot3DConfig, Camera3D, Viewport3D, Colormap, RenderMode};
1011
 
1112
 use garcalc_cas::Expr;
1213
 use serde::{Deserialize, Serialize};
garcalc-graph/src/plot3d.rsmodified
@@ -1,11 +1,27 @@
1
-//! 3D plotting (Sprint 4)
1
+//! 3D plotting engine
2
+//!
3
+//! Provides surface plotting with:
4
+//! - Explicit surfaces: z = f(x, y)
5
+//! - Parametric surfaces: (x(u,v), y(u,v), z(u,v))
6
+//! - Wireframe and filled rendering
7
+//! - Height-based colormaps
8
+//! - Rotation and zoom
29
 
3
-/// 3D camera state
10
+use cairo::Context;
11
+use garcalc_cas::{Evaluator, Expr};
12
+
13
+use crate::Color;
14
+
15
+/// 3D camera state (spherical coordinates around target)
416
 #[derive(Debug, Clone, Copy)]
517
 pub struct Camera3D {
18
+    /// Horizontal angle (radians)
619
     pub azimuth: f64,
20
+    /// Vertical angle (radians)
721
     pub elevation: f64,
22
+    /// Distance from target
823
     pub distance: f64,
24
+    /// Look-at point
925
     pub target: (f64, f64, f64),
1026
 }
1127
 
@@ -14,8 +30,679 @@ impl Default for Camera3D {
1430
         Self {
1531
             azimuth: 45.0_f64.to_radians(),
1632
             elevation: 30.0_f64.to_radians(),
17
-            distance: 20.0,
33
+            distance: 25.0,
1834
             target: (0.0, 0.0, 0.0),
1935
         }
2036
     }
2137
 }
38
+
39
+impl Camera3D {
40
+    /// Rotate the camera by delta angles
41
+    pub fn rotate(&mut self, d_azimuth: f64, d_elevation: f64) {
42
+        self.azimuth += d_azimuth;
43
+        self.elevation = (self.elevation + d_elevation)
44
+            .clamp(-89.0_f64.to_radians(), 89.0_f64.to_radians());
45
+    }
46
+
47
+    /// Zoom by a factor
48
+    pub fn zoom(&mut self, factor: f64) {
49
+        self.distance = (self.distance / factor).clamp(5.0, 100.0);
50
+    }
51
+
52
+    /// Get camera position in world coordinates
53
+    pub fn position(&self) -> (f64, f64, f64) {
54
+        let cos_elev = self.elevation.cos();
55
+        let sin_elev = self.elevation.sin();
56
+        let cos_azim = self.azimuth.cos();
57
+        let sin_azim = self.azimuth.sin();
58
+
59
+        (
60
+            self.target.0 + self.distance * cos_elev * cos_azim,
61
+            self.target.1 + self.distance * cos_elev * sin_azim,
62
+            self.target.2 + self.distance * sin_elev,
63
+        )
64
+    }
65
+}
66
+
67
+/// 3D viewport bounds
68
+#[derive(Debug, Clone, Copy)]
69
+pub struct Viewport3D {
70
+    pub x_min: f64,
71
+    pub x_max: f64,
72
+    pub y_min: f64,
73
+    pub y_max: f64,
74
+    pub z_min: f64,
75
+    pub z_max: f64,
76
+}
77
+
78
+impl Default for Viewport3D {
79
+    fn default() -> Self {
80
+        Self {
81
+            x_min: -5.0,
82
+            x_max: 5.0,
83
+            y_min: -5.0,
84
+            y_max: 5.0,
85
+            z_min: -5.0,
86
+            z_max: 5.0,
87
+        }
88
+    }
89
+}
90
+
91
+/// Colormap for surface coloring
92
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93
+pub enum Colormap {
94
+    Viridis,
95
+    Plasma,
96
+    Coolwarm,
97
+    Grayscale,
98
+}
99
+
100
+impl Colormap {
101
+    /// Map a value in [0, 1] to a color
102
+    pub fn color(&self, t: f64) -> Color {
103
+        let t = t.clamp(0.0, 1.0);
104
+        match self {
105
+            Colormap::Viridis => viridis(t),
106
+            Colormap::Plasma => plasma(t),
107
+            Colormap::Coolwarm => coolwarm(t),
108
+            Colormap::Grayscale => {
109
+                let v = (t * 255.0) as u8;
110
+                Color { r: v, g: v, b: v, a: 255 }
111
+            }
112
+        }
113
+    }
114
+}
115
+
116
+/// A 3D plottable surface
117
+#[derive(Debug, Clone)]
118
+pub enum Surface3D {
119
+    /// z = f(x, y)
120
+    Explicit {
121
+        expr: Expr,
122
+        x_var: String,
123
+        y_var: String,
124
+    },
125
+    /// Parametric: (x(u,v), y(u,v), z(u,v))
126
+    Parametric {
127
+        x_expr: Expr,
128
+        y_expr: Expr,
129
+        z_expr: Expr,
130
+        u_var: String,
131
+        v_var: String,
132
+        u_range: (f64, f64),
133
+        v_range: (f64, f64),
134
+    },
135
+}
136
+
137
+/// Render mode for surfaces
138
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139
+pub enum RenderMode {
140
+    Wireframe,
141
+    Filled,
142
+    FilledWithWireframe,
143
+}
144
+
145
+/// Configuration for 3D plot appearance
146
+#[derive(Debug, Clone)]
147
+pub struct Plot3DConfig {
148
+    pub background: Color,
149
+    pub axis_color: Color,
150
+    pub wireframe_color: Color,
151
+    pub colormap: Colormap,
152
+    pub render_mode: RenderMode,
153
+    pub grid_lines: usize,
154
+    pub show_axes: bool,
155
+    pub show_labels: bool,
156
+}
157
+
158
+impl Default for Plot3DConfig {
159
+    fn default() -> Self {
160
+        Self {
161
+            background: Color { r: 30, g: 30, b: 46, a: 255 },
162
+            axis_color: Color { r: 166, g: 173, b: 200, a: 255 },
163
+            wireframe_color: Color { r: 100, g: 100, b: 120, a: 255 },
164
+            colormap: Colormap::Viridis,
165
+            render_mode: RenderMode::FilledWithWireframe,
166
+            grid_lines: 40,
167
+            show_axes: true,
168
+            show_labels: true,
169
+        }
170
+    }
171
+}
172
+
173
+/// 3D graph state and renderer
174
+pub struct Graph3D {
175
+    pub camera: Camera3D,
176
+    pub viewport: Viewport3D,
177
+    pub config: Plot3DConfig,
178
+    pub surfaces: Vec<Surface3D>,
179
+}
180
+
181
+impl Default for Graph3D {
182
+    fn default() -> Self {
183
+        Self {
184
+            camera: Camera3D::default(),
185
+            viewport: Viewport3D::default(),
186
+            config: Plot3DConfig::default(),
187
+            surfaces: Vec::new(),
188
+        }
189
+    }
190
+}
191
+
192
+impl Graph3D {
193
+    pub fn new() -> Self {
194
+        Self::default()
195
+    }
196
+
197
+    /// Add an explicit surface z = f(x, y)
198
+    pub fn add_explicit(&mut self, expr: Expr) {
199
+        self.surfaces.push(Surface3D::Explicit {
200
+            expr,
201
+            x_var: "x".to_string(),
202
+            y_var: "y".to_string(),
203
+        });
204
+    }
205
+
206
+    /// Clear all surfaces
207
+    pub fn clear_surfaces(&mut self) {
208
+        self.surfaces.clear();
209
+    }
210
+
211
+    /// Reset camera to default
212
+    pub fn reset_camera(&mut self) {
213
+        self.camera = Camera3D::default();
214
+    }
215
+
216
+    /// Project 3D point to 2D screen coordinates
217
+    fn project(&self, x: f64, y: f64, z: f64, width: u32, height: u32) -> (f64, f64) {
218
+        let cam_pos = self.camera.position();
219
+
220
+        // View direction (camera looks at target)
221
+        let dx = self.camera.target.0 - cam_pos.0;
222
+        let dy = self.camera.target.1 - cam_pos.1;
223
+        let dz = self.camera.target.2 - cam_pos.2;
224
+        let dist = (dx * dx + dy * dy + dz * dz).sqrt();
225
+
226
+        // Normalize
227
+        let forward = (dx / dist, dy / dist, dz / dist);
228
+
229
+        // Up vector (world Z)
230
+        let world_up = (0.0, 0.0, 1.0);
231
+
232
+        // Right vector = forward x up
233
+        let right = (
234
+            forward.1 * world_up.2 - forward.2 * world_up.1,
235
+            forward.2 * world_up.0 - forward.0 * world_up.2,
236
+            forward.0 * world_up.1 - forward.1 * world_up.0,
237
+        );
238
+        let right_len = (right.0 * right.0 + right.1 * right.1 + right.2 * right.2).sqrt();
239
+        let right = (right.0 / right_len, right.1 / right_len, right.2 / right_len);
240
+
241
+        // Actual up = right x forward
242
+        let up = (
243
+            right.1 * forward.2 - right.2 * forward.1,
244
+            right.2 * forward.0 - right.0 * forward.2,
245
+            right.0 * forward.1 - right.1 * forward.0,
246
+        );
247
+
248
+        // Point relative to camera
249
+        let px = x - cam_pos.0;
250
+        let py = y - cam_pos.1;
251
+        let pz = z - cam_pos.2;
252
+
253
+        // Project onto camera plane
254
+        let cam_x = px * right.0 + py * right.1 + pz * right.2;
255
+        let cam_y = px * up.0 + py * up.1 + pz * up.2;
256
+        let cam_z = px * forward.0 + py * forward.1 + pz * forward.2;
257
+
258
+        // Perspective projection
259
+        let scale = if cam_z > 0.1 {
260
+            self.camera.distance / cam_z
261
+        } else {
262
+            self.camera.distance / 0.1
263
+        };
264
+
265
+        // Screen coordinates
266
+        let aspect = width as f64 / height as f64;
267
+        let fov_scale = 0.8;
268
+        let sx = width as f64 / 2.0 + cam_x * scale * height as f64 * fov_scale / aspect;
269
+        let sy = height as f64 / 2.0 - cam_y * scale * height as f64 * fov_scale;
270
+
271
+        (sx, sy)
272
+    }
273
+
274
+    /// Render the 3D graph to a Cairo context
275
+    pub fn render(&self, ctx: &Context, width: u32, height: u32) {
276
+        // Background
277
+        set_color(ctx, self.config.background);
278
+        ctx.rectangle(0.0, 0.0, width as f64, height as f64);
279
+        let _ = ctx.fill();
280
+
281
+        // Draw axes
282
+        if self.config.show_axes {
283
+            self.draw_axes(ctx, width, height);
284
+        }
285
+
286
+        // Draw surfaces
287
+        for surface in &self.surfaces {
288
+            self.draw_surface(ctx, surface, width, height);
289
+        }
290
+    }
291
+
292
+    fn draw_axes(&self, ctx: &Context, width: u32, height: u32) {
293
+        set_color(ctx, self.config.axis_color);
294
+        ctx.set_line_width(1.5);
295
+
296
+        let len = 6.0;
297
+
298
+        // X axis (red tint)
299
+        ctx.set_source_rgba(0.9, 0.4, 0.4, 1.0);
300
+        let (x0, y0) = self.project(0.0, 0.0, 0.0, width, height);
301
+        let (x1, y1) = self.project(len, 0.0, 0.0, width, height);
302
+        ctx.move_to(x0, y0);
303
+        ctx.line_to(x1, y1);
304
+        let _ = ctx.stroke();
305
+
306
+        // Y axis (green tint)
307
+        ctx.set_source_rgba(0.4, 0.9, 0.4, 1.0);
308
+        let (x1, y1) = self.project(0.0, len, 0.0, width, height);
309
+        ctx.move_to(x0, y0);
310
+        ctx.line_to(x1, y1);
311
+        let _ = ctx.stroke();
312
+
313
+        // Z axis (blue tint)
314
+        ctx.set_source_rgba(0.4, 0.4, 0.9, 1.0);
315
+        let (x1, y1) = self.project(0.0, 0.0, len, width, height);
316
+        ctx.move_to(x0, y0);
317
+        ctx.line_to(x1, y1);
318
+        let _ = ctx.stroke();
319
+
320
+        // Labels
321
+        if self.config.show_labels {
322
+            set_color(ctx, self.config.axis_color);
323
+            ctx.set_font_size(12.0);
324
+
325
+            let (lx, ly) = self.project(len + 0.5, 0.0, 0.0, width, height);
326
+            ctx.move_to(lx, ly);
327
+            let _ = ctx.show_text("X");
328
+
329
+            let (lx, ly) = self.project(0.0, len + 0.5, 0.0, width, height);
330
+            ctx.move_to(lx, ly);
331
+            let _ = ctx.show_text("Y");
332
+
333
+            let (lx, ly) = self.project(0.0, 0.0, len + 0.5, width, height);
334
+            ctx.move_to(lx, ly);
335
+            let _ = ctx.show_text("Z");
336
+        }
337
+    }
338
+
339
+    fn draw_surface(&self, ctx: &Context, surface: &Surface3D, width: u32, height: u32) {
340
+        match surface {
341
+            Surface3D::Explicit { expr, x_var, y_var } => {
342
+                self.draw_explicit_surface(ctx, expr, x_var, y_var, width, height);
343
+            }
344
+            Surface3D::Parametric { x_expr, y_expr, z_expr, u_var, v_var, u_range, v_range } => {
345
+                self.draw_parametric_surface(
346
+                    ctx, x_expr, y_expr, z_expr, u_var, v_var, *u_range, *v_range, width, height
347
+                );
348
+            }
349
+        }
350
+    }
351
+
352
+    fn draw_explicit_surface(
353
+        &self,
354
+        ctx: &Context,
355
+        expr: &Expr,
356
+        x_var: &str,
357
+        y_var: &str,
358
+        width: u32,
359
+        height: u32,
360
+    ) {
361
+        let mut evaluator = Evaluator::new();
362
+        let n = self.config.grid_lines;
363
+
364
+        let x_range = self.viewport.x_max - self.viewport.x_min;
365
+        let y_range = self.viewport.y_max - self.viewport.y_min;
366
+        let dx = x_range / n as f64;
367
+        let dy = y_range / n as f64;
368
+
369
+        // Sample the surface
370
+        let mut points: Vec<Vec<Option<(f64, f64, f64)>>> = Vec::with_capacity(n + 1);
371
+        let mut z_min = f64::INFINITY;
372
+        let mut z_max = f64::NEG_INFINITY;
373
+
374
+        for i in 0..=n {
375
+            let mut row = Vec::with_capacity(n + 1);
376
+            let x = self.viewport.x_min + i as f64 * dx;
377
+
378
+            for j in 0..=n {
379
+                let y = self.viewport.y_min + j as f64 * dy;
380
+
381
+                evaluator.set_var(x_var, Expr::Float(x));
382
+                evaluator.set_var(y_var, Expr::Float(y));
383
+
384
+                if let Ok(result) = evaluator.eval(expr) {
385
+                    if let Ok(z) = expr_to_f64(&result) {
386
+                        if z.is_finite() && z >= self.viewport.z_min && z <= self.viewport.z_max {
387
+                            z_min = z_min.min(z);
388
+                            z_max = z_max.max(z);
389
+                            row.push(Some((x, y, z)));
390
+                        } else {
391
+                            row.push(None);
392
+                        }
393
+                    } else {
394
+                        row.push(None);
395
+                    }
396
+                } else {
397
+                    row.push(None);
398
+                }
399
+            }
400
+            points.push(row);
401
+        }
402
+
403
+        // Avoid division by zero
404
+        if (z_max - z_min).abs() < 1e-10 {
405
+            z_max = z_min + 1.0;
406
+        }
407
+
408
+        // Collect quads for depth sorting
409
+        let mut quads: Vec<Quad> = Vec::new();
410
+
411
+        for i in 0..n {
412
+            for j in 0..n {
413
+                if let (Some(p00), Some(p10), Some(p11), Some(p01)) = (
414
+                    points[i][j],
415
+                    points[i + 1][j],
416
+                    points[i + 1][j + 1],
417
+                    points[i][j + 1],
418
+                ) {
419
+                    let avg_z = (p00.2 + p10.2 + p11.2 + p01.2) / 4.0;
420
+                    let t = (avg_z - z_min) / (z_max - z_min);
421
+                    let color = self.config.colormap.color(t);
422
+
423
+                    // Calculate depth for sorting (distance from camera)
424
+                    let cam_pos = self.camera.position();
425
+                    let cx = (p00.0 + p10.0 + p11.0 + p01.0) / 4.0;
426
+                    let cy = (p00.1 + p10.1 + p11.1 + p01.1) / 4.0;
427
+                    let cz = (p00.2 + p10.2 + p11.2 + p01.2) / 4.0;
428
+                    let depth = (cx - cam_pos.0).powi(2)
429
+                              + (cy - cam_pos.1).powi(2)
430
+                              + (cz - cam_pos.2).powi(2);
431
+
432
+                    quads.push(Quad {
433
+                        corners: [p00, p10, p11, p01],
434
+                        color,
435
+                        depth,
436
+                    });
437
+                }
438
+            }
439
+        }
440
+
441
+        // Sort by depth (painter's algorithm - far to near)
442
+        quads.sort_by(|a, b| b.depth.partial_cmp(&a.depth).unwrap_or(std::cmp::Ordering::Equal));
443
+
444
+        // Draw quads
445
+        for quad in &quads {
446
+            let corners: Vec<(f64, f64)> = quad.corners
447
+                .iter()
448
+                .map(|p| self.project(p.0, p.1, p.2, width, height))
449
+                .collect();
450
+
451
+            if self.config.render_mode != RenderMode::Wireframe {
452
+                // Fill
453
+                set_color(ctx, quad.color);
454
+                ctx.move_to(corners[0].0, corners[0].1);
455
+                for c in &corners[1..] {
456
+                    ctx.line_to(c.0, c.1);
457
+                }
458
+                ctx.close_path();
459
+                let _ = ctx.fill();
460
+            }
461
+
462
+            if self.config.render_mode != RenderMode::Filled {
463
+                // Wireframe
464
+                set_color(ctx, self.config.wireframe_color);
465
+                ctx.set_line_width(0.5);
466
+                ctx.move_to(corners[0].0, corners[0].1);
467
+                for c in &corners[1..] {
468
+                    ctx.line_to(c.0, c.1);
469
+                }
470
+                ctx.close_path();
471
+                let _ = ctx.stroke();
472
+            }
473
+        }
474
+    }
475
+
476
+    fn draw_parametric_surface(
477
+        &self,
478
+        ctx: &Context,
479
+        x_expr: &Expr,
480
+        y_expr: &Expr,
481
+        z_expr: &Expr,
482
+        u_var: &str,
483
+        v_var: &str,
484
+        u_range: (f64, f64),
485
+        v_range: (f64, f64),
486
+        width: u32,
487
+        height: u32,
488
+    ) {
489
+        let mut evaluator = Evaluator::new();
490
+        let n = self.config.grid_lines;
491
+
492
+        let du = (u_range.1 - u_range.0) / n as f64;
493
+        let dv = (v_range.1 - v_range.0) / n as f64;
494
+
495
+        // Sample the surface
496
+        let mut points: Vec<Vec<Option<(f64, f64, f64)>>> = Vec::with_capacity(n + 1);
497
+        let mut z_min = f64::INFINITY;
498
+        let mut z_max = f64::NEG_INFINITY;
499
+
500
+        for i in 0..=n {
501
+            let mut row = Vec::with_capacity(n + 1);
502
+            let u = u_range.0 + i as f64 * du;
503
+
504
+            for j in 0..=n {
505
+                let v = v_range.0 + j as f64 * dv;
506
+
507
+                evaluator.set_var(u_var, Expr::Float(u));
508
+                evaluator.set_var(v_var, Expr::Float(v));
509
+
510
+                let x_result = evaluator.eval(x_expr);
511
+                let y_result = evaluator.eval(y_expr);
512
+                let z_result = evaluator.eval(z_expr);
513
+
514
+                if let (Ok(xr), Ok(yr), Ok(zr)) = (x_result, y_result, z_result) {
515
+                    if let (Ok(x), Ok(y), Ok(z)) = (expr_to_f64(&xr), expr_to_f64(&yr), expr_to_f64(&zr)) {
516
+                        if x.is_finite() && y.is_finite() && z.is_finite() {
517
+                            z_min = z_min.min(z);
518
+                            z_max = z_max.max(z);
519
+                            row.push(Some((x, y, z)));
520
+                        } else {
521
+                            row.push(None);
522
+                        }
523
+                    } else {
524
+                        row.push(None);
525
+                    }
526
+                } else {
527
+                    row.push(None);
528
+                }
529
+            }
530
+            points.push(row);
531
+        }
532
+
533
+        if (z_max - z_min).abs() < 1e-10 {
534
+            z_max = z_min + 1.0;
535
+        }
536
+
537
+        // Collect and sort quads
538
+        let mut quads: Vec<Quad> = Vec::new();
539
+
540
+        for i in 0..n {
541
+            for j in 0..n {
542
+                if let (Some(p00), Some(p10), Some(p11), Some(p01)) = (
543
+                    points[i][j],
544
+                    points[i + 1][j],
545
+                    points[i + 1][j + 1],
546
+                    points[i][j + 1],
547
+                ) {
548
+                    let avg_z = (p00.2 + p10.2 + p11.2 + p01.2) / 4.0;
549
+                    let t = (avg_z - z_min) / (z_max - z_min);
550
+                    let color = self.config.colormap.color(t);
551
+
552
+                    let cam_pos = self.camera.position();
553
+                    let cx = (p00.0 + p10.0 + p11.0 + p01.0) / 4.0;
554
+                    let cy = (p00.1 + p10.1 + p11.1 + p01.1) / 4.0;
555
+                    let cz = (p00.2 + p10.2 + p11.2 + p01.2) / 4.0;
556
+                    let depth = (cx - cam_pos.0).powi(2)
557
+                              + (cy - cam_pos.1).powi(2)
558
+                              + (cz - cam_pos.2).powi(2);
559
+
560
+                    quads.push(Quad {
561
+                        corners: [p00, p10, p11, p01],
562
+                        color,
563
+                        depth,
564
+                    });
565
+                }
566
+            }
567
+        }
568
+
569
+        quads.sort_by(|a, b| b.depth.partial_cmp(&a.depth).unwrap_or(std::cmp::Ordering::Equal));
570
+
571
+        for quad in &quads {
572
+            let corners: Vec<(f64, f64)> = quad.corners
573
+                .iter()
574
+                .map(|p| self.project(p.0, p.1, p.2, width, height))
575
+                .collect();
576
+
577
+            if self.config.render_mode != RenderMode::Wireframe {
578
+                set_color(ctx, quad.color);
579
+                ctx.move_to(corners[0].0, corners[0].1);
580
+                for c in &corners[1..] {
581
+                    ctx.line_to(c.0, c.1);
582
+                }
583
+                ctx.close_path();
584
+                let _ = ctx.fill();
585
+            }
586
+
587
+            if self.config.render_mode != RenderMode::Filled {
588
+                set_color(ctx, self.config.wireframe_color);
589
+                ctx.set_line_width(0.5);
590
+                ctx.move_to(corners[0].0, corners[0].1);
591
+                for c in &corners[1..] {
592
+                    ctx.line_to(c.0, c.1);
593
+                }
594
+                ctx.close_path();
595
+                let _ = ctx.stroke();
596
+            }
597
+        }
598
+    }
599
+}
600
+
601
+/// A quad face for depth sorting
602
+struct Quad {
603
+    corners: [(f64, f64, f64); 4],
604
+    color: Color,
605
+    depth: f64,
606
+}
607
+
608
+/// Set Cairo source color
609
+fn set_color(ctx: &Context, color: Color) {
610
+    ctx.set_source_rgba(
611
+        color.r as f64 / 255.0,
612
+        color.g as f64 / 255.0,
613
+        color.b as f64 / 255.0,
614
+        color.a as f64 / 255.0,
615
+    );
616
+}
617
+
618
+/// Convert Expr to f64
619
+fn expr_to_f64(expr: &Expr) -> Result<f64, ()> {
620
+    match expr {
621
+        Expr::Integer(n) => Ok(*n as f64),
622
+        Expr::Float(x) => Ok(*x),
623
+        Expr::Rational(r) => Ok(r.to_f64()),
624
+        _ => Err(()),
625
+    }
626
+}
627
+
628
+// Colormap implementations
629
+
630
+fn viridis(t: f64) -> Color {
631
+    // Approximation of viridis colormap
632
+    let r = (0.267 + t * (0.329 + t * (1.421 + t * (-1.685 + t * 0.668)))).clamp(0.0, 1.0);
633
+    let g = (0.004 + t * (1.260 + t * (-0.569 + t * 0.305))).clamp(0.0, 1.0);
634
+    let b = (0.329 + t * (1.440 + t * (-2.814 + t * (2.768 - t * 0.723)))).clamp(0.0, 1.0);
635
+    Color {
636
+        r: (r * 255.0) as u8,
637
+        g: (g * 255.0) as u8,
638
+        b: (b * 255.0) as u8,
639
+        a: 255,
640
+    }
641
+}
642
+
643
+fn plasma(t: f64) -> Color {
644
+    // Approximation of plasma colormap
645
+    let r = (0.050 + t * (2.735 + t * (-2.811 + t * 0.826))).clamp(0.0, 1.0);
646
+    let g = (0.029 + t * (-0.278 + t * (2.149 + t * (-1.100)))).clamp(0.0, 1.0);
647
+    let b = (0.533 + t * (0.667 + t * (-2.519 + t * 1.319))).clamp(0.0, 1.0);
648
+    Color {
649
+        r: (r * 255.0) as u8,
650
+        g: (g * 255.0) as u8,
651
+        b: (b * 255.0) as u8,
652
+        a: 255,
653
+    }
654
+}
655
+
656
+fn coolwarm(t: f64) -> Color {
657
+    // Blue (cool) to red (warm)
658
+    let r = if t < 0.5 {
659
+        0.2 + t * 1.6
660
+    } else {
661
+        1.0
662
+    };
663
+    let g = if t < 0.5 {
664
+        0.2 + t * 1.2
665
+    } else {
666
+        0.8 - (t - 0.5) * 1.6
667
+    };
668
+    let b = if t < 0.5 {
669
+        1.0
670
+    } else {
671
+        1.0 - (t - 0.5) * 1.6
672
+    };
673
+    Color {
674
+        r: (r.clamp(0.0, 1.0) * 255.0) as u8,
675
+        g: (g.clamp(0.0, 1.0) * 255.0) as u8,
676
+        b: (b.clamp(0.0, 1.0) * 255.0) as u8,
677
+        a: 255,
678
+    }
679
+}
680
+
681
+#[cfg(test)]
682
+mod tests {
683
+    use super::*;
684
+
685
+    #[test]
686
+    fn test_camera_default() {
687
+        let cam = Camera3D::default();
688
+        assert!((cam.azimuth - 45.0_f64.to_radians()).abs() < 0.01);
689
+        assert!((cam.elevation - 30.0_f64.to_radians()).abs() < 0.01);
690
+    }
691
+
692
+    #[test]
693
+    fn test_camera_rotate() {
694
+        let mut cam = Camera3D::default();
695
+        cam.rotate(0.1, 0.1);
696
+        assert!(cam.azimuth > 45.0_f64.to_radians());
697
+        assert!(cam.elevation > 30.0_f64.to_radians());
698
+    }
699
+
700
+    #[test]
701
+    fn test_colormap_bounds() {
702
+        let cm = Colormap::Viridis;
703
+        let c0 = cm.color(0.0);
704
+        let c1 = cm.color(1.0);
705
+        assert!(c0.r < 100); // Dark at 0
706
+        assert!(c1.g > 200); // Yellowish at 1
707
+    }
708
+}