Skip to main content

runmat_plot/plots/
contour.rs

1//! Contour plot implementation (iso-lines on a surface or base plane).
2
3use crate::core::{
4    vertex_utils, BoundingBox, DrawCall, GpuVertexBuffer, Material, PipelineType, RenderData,
5    Vertex,
6};
7use glam::{Vec3, Vec4};
8
9#[derive(Debug, Clone)]
10pub struct ContourPlot {
11    pub base_z: f32,
12    pub force_3d: bool,
13    pub label: Option<String>,
14    pub visible: bool,
15    pub line_width: f32,
16    vertices: Option<Vec<Vertex>>,
17    gpu_vertices: Option<GpuVertexBuffer>,
18    vertex_count: usize,
19    bounds: Option<BoundingBox>,
20}
21
22impl ContourPlot {
23    /// Create a contour plot from CPU vertices.
24    pub fn from_vertices(vertices: Vec<Vertex>, base_z: f32, bounds: BoundingBox) -> Self {
25        Self {
26            base_z,
27            force_3d: false,
28            label: None,
29            visible: true,
30            line_width: 1.0,
31            vertex_count: vertices.len(),
32            vertices: Some(vertices),
33            gpu_vertices: None,
34            bounds: Some(bounds),
35        }
36    }
37
38    /// Create a contour plot backed by a GPU vertex buffer.
39    pub fn from_gpu_buffer(
40        buffer: GpuVertexBuffer,
41        vertex_count: usize,
42        base_z: f32,
43        bounds: BoundingBox,
44    ) -> Self {
45        Self {
46            base_z,
47            force_3d: false,
48            label: None,
49            visible: true,
50            line_width: 1.0,
51            vertex_count,
52            vertices: None,
53            gpu_vertices: Some(buffer),
54            bounds: Some(bounds),
55        }
56    }
57
58    pub fn with_label<S: Into<String>>(mut self, label: S) -> Self {
59        self.label = Some(label.into());
60        self
61    }
62
63    pub fn with_force_3d(mut self, force_3d: bool) -> Self {
64        self.force_3d = force_3d;
65        self
66    }
67
68    pub fn is_3d(&self) -> bool {
69        self.force_3d || (self.bounds().max.z - self.bounds().min.z).abs() > f32::EPSILON
70    }
71
72    pub fn set_visible(&mut self, visible: bool) {
73        self.visible = visible;
74    }
75
76    pub fn with_line_width(mut self, line_width: f32) -> Self {
77        self.line_width = line_width.max(0.5);
78        self
79    }
80
81    pub fn vertices(&mut self) -> &Vec<Vertex> {
82        if self.vertices.is_none() {
83            self.vertices = Some(Vec::new());
84        }
85        self.vertices.as_ref().unwrap()
86    }
87
88    pub fn bounds(&self) -> BoundingBox {
89        self.bounds.unwrap_or_default()
90    }
91
92    pub fn cpu_vertices(&self) -> Option<&[Vertex]> {
93        self.vertices.as_deref()
94    }
95
96    pub fn render_data_with_viewport(&mut self, viewport_px: Option<(u32, u32)>) -> RenderData {
97        if self.gpu_vertices.is_some() {
98            return self.render_data();
99        }
100
101        let bounds = self.bounds();
102        let (vertices, vertex_count, pipeline_type, render_bounds) = if self.line_width > 1.0 {
103            let Some(viewport_px) = viewport_px else {
104                return self.render_data();
105            };
106            let data_per_px = crate::core::data_units_per_px(&bounds, viewport_px);
107            let width_data = self.line_width.max(0.1) * data_per_px;
108            let verts = self.vertices().clone();
109            let mut thick = Vec::new();
110            for segment in verts.chunks_exact(2) {
111                let color = Vec4::from_array(segment[0].color);
112                if self.is_3d() {
113                    thick.extend(create_thick_segment_3d(
114                        Vec3::from_array(segment[0].position),
115                        Vec3::from_array(segment[1].position),
116                        color,
117                        width_data * 0.5,
118                    ));
119                } else {
120                    let x = [segment[0].position[0] as f64, segment[1].position[0] as f64];
121                    let y = [segment[0].position[1] as f64, segment[1].position[1] as f64];
122                    thick.extend(vertex_utils::create_thick_polyline(
123                        &x, &y, color, width_data,
124                    ));
125                }
126            }
127            let count = thick.len();
128            let render_bounds = expanded_bounds_for_vertices(bounds, &thick);
129            (thick, count, PipelineType::Triangles, render_bounds)
130        } else {
131            let verts = self.vertices().clone();
132            let count = verts.len();
133            (verts, count, PipelineType::Lines, bounds)
134        };
135
136        let material = Material {
137            albedo: Vec4::ONE,
138            roughness: self.line_width.max(0.0),
139            ..Default::default()
140        };
141
142        let draw_call = DrawCall {
143            vertex_offset: 0,
144            vertex_count,
145            index_offset: None,
146            index_count: None,
147            instance_count: 1,
148        };
149
150        RenderData {
151            pipeline_type,
152            vertices,
153            indices: None,
154            gpu_vertices: None,
155            bounds: Some(render_bounds),
156            material,
157            draw_calls: vec![draw_call],
158            image: None,
159        }
160    }
161
162    pub fn render_data(&mut self) -> RenderData {
163        let using_gpu = self.gpu_vertices.is_some();
164        let bounds = self.bounds();
165        let (vertices, vertex_count, gpu_vertices, pipeline_type, render_bounds) = if using_gpu {
166            (
167                Vec::new(),
168                self.vertex_count,
169                self.gpu_vertices.clone(),
170                PipelineType::Lines,
171                bounds,
172            )
173        } else {
174            let verts = self.vertices().clone();
175            if self.line_width > 1.0 {
176                let mut thick = Vec::new();
177                for segment in verts.chunks_exact(2) {
178                    let color = Vec4::from_array(segment[0].color);
179                    if self.is_3d() {
180                        thick.extend(create_thick_segment_3d(
181                            Vec3::from_array(segment[0].position),
182                            Vec3::from_array(segment[1].position),
183                            color,
184                            self.line_width.max(0.5) * 0.01,
185                        ));
186                    } else {
187                        let x = [segment[0].position[0] as f64, segment[1].position[0] as f64];
188                        let y = [segment[0].position[1] as f64, segment[1].position[1] as f64];
189                        thick.extend(vertex_utils::create_thick_polyline(
190                            &x,
191                            &y,
192                            color,
193                            self.line_width,
194                        ));
195                    }
196                }
197                let count = thick.len();
198                let render_bounds = expanded_bounds_for_vertices(bounds, &thick);
199                (thick, count, None, PipelineType::Triangles, render_bounds)
200            } else {
201                let count = verts.len();
202                (verts, count, None, PipelineType::Lines, bounds)
203            }
204        };
205
206        let material = Material {
207            albedo: Vec4::ONE,
208            roughness: self.line_width.max(0.0),
209            ..Default::default()
210        };
211
212        let draw_call = DrawCall {
213            vertex_offset: 0,
214            vertex_count,
215            index_offset: None,
216            index_count: None,
217            instance_count: 1,
218        };
219
220        RenderData {
221            pipeline_type,
222            vertices,
223            indices: None,
224            gpu_vertices,
225            bounds: Some(render_bounds),
226            material,
227            draw_calls: vec![draw_call],
228            image: None,
229        }
230    }
231
232    pub fn estimated_memory_usage(&self) -> usize {
233        self.vertices
234            .as_ref()
235            .map(|v| v.len() * std::mem::size_of::<Vertex>())
236            .unwrap_or(0)
237    }
238}
239
240pub fn contour_bounds(x_min: f32, x_max: f32, y_min: f32, y_max: f32, base_z: f32) -> BoundingBox {
241    BoundingBox::new(
242        Vec3::new(x_min, y_min, base_z),
243        Vec3::new(x_max, y_max, base_z),
244    )
245}
246
247pub fn contour_bounds_3d(
248    x_min: f32,
249    x_max: f32,
250    y_min: f32,
251    y_max: f32,
252    z_min: f32,
253    z_max: f32,
254) -> BoundingBox {
255    BoundingBox::new(
256        Vec3::new(x_min, y_min, z_min),
257        Vec3::new(x_max, y_max, z_max),
258    )
259}
260
261fn expanded_bounds_for_vertices(mut bounds: BoundingBox, vertices: &[Vertex]) -> BoundingBox {
262    for vertex in vertices {
263        bounds.expand(Vec3::from_array(vertex.position));
264    }
265    bounds
266}
267
268fn create_thick_segment_3d(start: Vec3, end: Vec3, color: Vec4, half_width: f32) -> Vec<Vertex> {
269    let dir = (end - start).normalize_or_zero();
270    let mut normal = dir.cross(Vec3::Z);
271    if normal.length_squared() < 1e-6 {
272        normal = dir.cross(Vec3::X);
273    }
274    let normal = normal.normalize_or_zero() * half_width.max(0.0001);
275    let v0 = start + normal;
276    let v1 = end + normal;
277    let v2 = end - normal;
278    let v3 = start - normal;
279    vec![
280        Vertex::new(v0, color),
281        Vertex::new(v1, color),
282        Vertex::new(v2, color),
283        Vertex::new(v0, color),
284        Vertex::new(v2, color),
285        Vertex::new(v3, color),
286    ]
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn test_vertex(x: f32, y: f32, z: f32) -> Vertex {
294        Vertex::new(Vec3::new(x, y, z), Vec4::ONE)
295    }
296
297    #[test]
298    fn viewport_thick_contour_bounds_include_extruded_geometry() {
299        let bounds = BoundingBox::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0));
300        let mut contour = ContourPlot::from_vertices(
301            vec![test_vertex(0.0, 0.0, 0.0), test_vertex(1.0, 0.0, 0.0)],
302            0.0,
303            bounds,
304        )
305        .with_line_width(2.0);
306
307        let render_data = contour.render_data_with_viewport(Some((100, 100)));
308        let render_bounds = render_data.bounds.expect("bounds");
309
310        assert!(render_bounds.min.y < bounds.min.y);
311        assert!(render_bounds.max.y > bounds.max.y);
312    }
313
314    #[test]
315    fn non_viewport_thick_3d_contour_bounds_include_extruded_geometry() {
316        let bounds = BoundingBox::new(Vec3::new(0.0, 0.0, 1.0), Vec3::new(0.0, 1.0, 1.0));
317        let mut contour = ContourPlot::from_vertices(
318            vec![test_vertex(0.0, 0.0, 1.0), test_vertex(0.0, 1.0, 1.0)],
319            0.0,
320            bounds,
321        )
322        .with_force_3d(true)
323        .with_line_width(2.0);
324
325        let render_data = contour.render_data();
326        let render_bounds = render_data.bounds.expect("bounds");
327
328        assert!(render_bounds.min.x < bounds.min.x);
329        assert!(render_bounds.max.x > bounds.max.x);
330    }
331
332    #[test]
333    fn viewport_thick_3d_contour_uses_half_width_data() {
334        let bounds = BoundingBox::new(Vec3::new(0.0, 0.0, 1.0), Vec3::new(1.0, 1.0, 1.0));
335        let mut contour = ContourPlot::from_vertices(
336            vec![test_vertex(0.0, 0.0, 1.0), test_vertex(1.0, 0.0, 1.0)],
337            0.0,
338            bounds,
339        )
340        .with_force_3d(true)
341        .with_line_width(4.0);
342
343        let render_data = contour.render_data_with_viewport(Some((100, 100)));
344        let render_bounds = render_data.bounds.expect("bounds");
345
346        assert!((render_bounds.min.y - -0.02).abs() < 1e-6);
347    }
348}