Skip to main content

plotlars_core/plots/
mesh3d.rs

1use bon::bon;
2
3use crate::{
4    components::{ColorBar, FacetConfig, IntensityMode, Legend, Lighting, Palette, Rgb, Text},
5    ir::data::ColumnData,
6    ir::layout::LayoutIR,
7    ir::trace::{Mesh3DIR as Mesh3DIRStruct, TraceIR},
8};
9use polars::frame::DataFrame;
10
11/// A structure representing a 3D mesh plot.
12///
13/// The `Mesh3D` struct is designed to create and customize 3D mesh visualizations
14/// with support for explicit triangulation, intensity-based coloring, and various
15/// lighting effects. It can handle both auto-triangulated point clouds and
16/// explicitly defined mesh connectivity through triangle indices.
17///
18/// # Backend Support
19///
20/// | Backend | Supported |
21/// |---------|-----------|
22/// | Plotly  | Yes       |
23/// | Plotters| --        |
24///
25/// # Arguments
26///
27/// * `data` - A reference to the `DataFrame` containing the mesh data.
28/// * `x` - A string slice specifying the column name for x-axis vertex coordinates.
29/// * `y` - A string slice specifying the column name for y-axis vertex coordinates.
30/// * `z` - A string slice specifying the column name for z-axis vertex coordinates.
31/// * `i` - An optional string slice specifying the column name for first vertex indices of triangles.
32/// * `j` - An optional string slice specifying the column name for second vertex indices of triangles.
33/// * `k` - An optional string slice specifying the column name for third vertex indices of triangles.
34/// * `intensity` - An optional string slice specifying the column name for intensity values used in gradient coloring.
35/// * `intensity_mode` - An optional `IntensityMode` specifying whether intensity applies to vertices or cells.
36/// * `color` - An optional `Rgb` value for uniform mesh coloring.
37/// * `color_bar` - An optional reference to a `ColorBar` for customizing the color legend.
38/// * `color_scale` - An optional `Palette` defining the color gradient for intensity mapping.
39/// * `reverse_scale` - An optional boolean to reverse the color scale direction.
40/// * `show_scale` - An optional boolean to show/hide the color bar.
41/// * `opacity` - An optional `f64` value specifying mesh transparency (range: 0.0 to 1.0).
42/// * `flat_shading` - An optional boolean for angular (true) vs smooth (false) shading.
43/// * `lighting` - An optional reference to a `Lighting` struct for custom lighting settings.
44/// * `light_position` - An optional tuple `(x, y, z)` specifying the light source position.
45/// * `delaunay_axis` - An optional string specifying the axis for Delaunay triangulation ("x", "y", or "z").
46/// * `contour` - An optional boolean to enable contour lines on the mesh.
47/// * `facet` - An optional string slice specifying the column name to be used for faceting (creating multiple subplots).
48/// * `facet_config` - An optional reference to a `FacetConfig` struct for customizing facet behavior (grid dimensions, scales, gaps, etc.).
49/// * `plot_title` - An optional `Text` struct specifying the plot title.
50/// * `x_title` - An optional `Text` struct for the x-axis title.
51/// * `y_title` - An optional `Text` struct for the y-axis title.
52/// * `z_title` - An optional `Text` struct for the z-axis title.
53/// * `legend` - An optional reference to a `Legend` struct for legend customization.
54///
55/// # Example
56///
57/// ```rust
58/// use plotlars::{Lighting, Mesh3D, Plot, Rgb, Text};
59/// use polars::prelude::*;
60///
61/// let mut x = Vec::new();
62/// let mut y = Vec::new();
63/// let mut z = Vec::new();
64///
65/// let n = 20;
66/// for i in 0..n {
67///     for j in 0..n {
68///         let xi = (i as f64 / (n - 1) as f64) * 2.0 - 1.0;
69///         let yj = (j as f64 / (n - 1) as f64) * 2.0 - 1.0;
70///         x.push(xi);
71///         y.push(yj);
72///         z.push(0.3 * ((xi * 3.0).sin() + (yj * 3.0).cos()));
73///     }
74/// }
75///
76/// let dataset = DataFrame::new(x.len(), vec![
77///     Column::new("x".into(), x),
78///     Column::new("y".into(), y),
79///     Column::new("z".into(), z),
80/// ])
81/// .unwrap();
82///
83/// Mesh3D::builder()
84///     .data(&dataset)
85///     .x("x")
86///     .y("y")
87///     .z("z")
88///     .color(Rgb(200, 200, 255))
89///     .lighting(
90///         &Lighting::new()
91///             .ambient(0.5)
92///             .diffuse(0.8)
93///             .specular(0.5)
94///             .roughness(0.2)
95///             .fresnel(0.2),
96///     )
97///     .light_position((1, 1, 2))
98///     .opacity(1.0)
99///     .flat_shading(false)
100///     .contour(true)
101///     .plot_title(
102///         Text::from("Mesh 3D Plot")
103///             .font("Arial")
104///             .size(22),
105///     )
106///     .build()
107///     .plot();
108/// ```
109///
110/// ![Example](https://imgur.com/bljzmw5.png)
111#[derive(Clone)]
112#[allow(dead_code)]
113pub struct Mesh3D {
114    traces: Vec<TraceIR>,
115    layout: LayoutIR,
116}
117
118#[bon]
119impl Mesh3D {
120    #[builder(on(String, into), on(Text, into))]
121    pub fn new(
122        data: &DataFrame,
123        x: &str,
124        y: &str,
125        z: &str,
126        i: Option<&str>,
127        j: Option<&str>,
128        k: Option<&str>,
129        intensity: Option<&str>,
130        intensity_mode: Option<IntensityMode>,
131        color: Option<Rgb>,
132        color_bar: Option<&ColorBar>,
133        color_scale: Option<Palette>,
134        _reverse_scale: Option<bool>,
135        _show_scale: Option<bool>,
136        opacity: Option<f64>,
137        flat_shading: Option<bool>,
138        lighting: Option<&Lighting>,
139        light_position: Option<(i32, i32, i32)>,
140        delaunay_axis: Option<&str>,
141        contour: Option<bool>,
142        facet: Option<&str>,
143        facet_config: Option<&FacetConfig>,
144        plot_title: Option<Text>,
145        x_title: Option<Text>,
146        y_title: Option<Text>,
147        z_title: Option<Text>,
148        legend: Option<&Legend>,
149    ) -> Self {
150        let grid = facet.map(|facet_column| {
151            let config = facet_config.cloned().unwrap_or_default();
152            let facet_categories =
153                crate::data::get_unique_groups(data, facet_column, config.sorter);
154            let n_facets = facet_categories.len();
155            let (ncols, nrows) =
156                crate::faceting::calculate_grid_dimensions(n_facets, config.cols, config.rows);
157            crate::ir::facet::GridSpec {
158                kind: crate::ir::facet::FacetKind::Scene,
159                rows: nrows,
160                cols: ncols,
161                h_gap: config.h_gap,
162                v_gap: config.v_gap,
163                scales: config.scales.clone(),
164                n_facets,
165                facet_categories,
166                title_style: config.title_style.clone(),
167                x_title: None,
168                y_title: None,
169                x_axis: None,
170                y_axis: None,
171                legend_title: None,
172                legend: legend.cloned(),
173            }
174        });
175
176        let traces = match facet {
177            Some(facet_column) => {
178                let config = facet_config.cloned().unwrap_or_default();
179                Self::create_ir_traces_faceted(
180                    data,
181                    x,
182                    y,
183                    z,
184                    i,
185                    j,
186                    k,
187                    intensity,
188                    intensity_mode,
189                    color,
190                    color_bar,
191                    color_scale,
192                    opacity,
193                    flat_shading,
194                    lighting,
195                    light_position,
196                    delaunay_axis,
197                    contour,
198                    facet_column,
199                    &config,
200                )
201            }
202            None => Self::create_ir_traces(
203                data,
204                x,
205                y,
206                z,
207                i,
208                j,
209                k,
210                intensity,
211                intensity_mode,
212                color,
213                color_bar,
214                color_scale,
215                opacity,
216                flat_shading,
217                lighting,
218                light_position,
219                delaunay_axis,
220                contour,
221            ),
222        };
223
224        let layout = LayoutIR {
225            title: plot_title,
226            x_title,
227            y_title,
228            y2_title: None,
229            z_title,
230            legend_title: None,
231            legend: if grid.is_some() {
232                None
233            } else {
234                legend.cloned()
235            },
236            dimensions: None,
237            bar_mode: None,
238            box_mode: None,
239            box_gap: None,
240            margin_bottom: None,
241            axes_2d: None,
242            scene_3d: None,
243            polar: None,
244            mapbox: None,
245            grid,
246            annotations: vec![],
247        };
248
249        Self { traces, layout }
250    }
251}
252
253#[bon]
254impl Mesh3D {
255    #[builder(
256        start_fn = try_builder,
257        finish_fn = try_build,
258        builder_type = Mesh3DTryBuilder,
259        on(String, into),
260        on(Text, into),
261    )]
262    pub fn try_new(
263        data: &DataFrame,
264        x: &str,
265        y: &str,
266        z: &str,
267        i: Option<&str>,
268        j: Option<&str>,
269        k: Option<&str>,
270        intensity: Option<&str>,
271        intensity_mode: Option<IntensityMode>,
272        color: Option<Rgb>,
273        color_bar: Option<&ColorBar>,
274        color_scale: Option<Palette>,
275        _reverse_scale: Option<bool>,
276        _show_scale: Option<bool>,
277        opacity: Option<f64>,
278        flat_shading: Option<bool>,
279        lighting: Option<&Lighting>,
280        light_position: Option<(i32, i32, i32)>,
281        delaunay_axis: Option<&str>,
282        contour: Option<bool>,
283        facet: Option<&str>,
284        facet_config: Option<&FacetConfig>,
285        plot_title: Option<Text>,
286        x_title: Option<Text>,
287        y_title: Option<Text>,
288        z_title: Option<Text>,
289        legend: Option<&Legend>,
290    ) -> Result<Self, crate::io::PlotlarsError> {
291        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
292            Self::__orig_new(
293                data,
294                x,
295                y,
296                z,
297                i,
298                j,
299                k,
300                intensity,
301                intensity_mode,
302                color,
303                color_bar,
304                color_scale,
305                _reverse_scale,
306                _show_scale,
307                opacity,
308                flat_shading,
309                lighting,
310                light_position,
311                delaunay_axis,
312                contour,
313                facet,
314                facet_config,
315                plot_title,
316                x_title,
317                y_title,
318                z_title,
319                legend,
320            )
321        }))
322        .map_err(|panic| {
323            let msg = panic
324                .downcast_ref::<String>()
325                .cloned()
326                .or_else(|| panic.downcast_ref::<&str>().map(|s| s.to_string()))
327                .unwrap_or_else(|| "unknown error".to_string());
328            crate::io::PlotlarsError::PlotBuild { message: msg }
329        })
330    }
331}
332
333impl Mesh3D {
334    fn get_integer_column(data: &DataFrame, column: &str) -> Vec<usize> {
335        let column_data = data.column(column).expect("Column not found");
336
337        column_data
338            .cast(&polars::prelude::DataType::UInt32)
339            .expect("Failed to cast to u32")
340            .u32()
341            .expect("Failed to extract u32 values")
342            .into_iter()
343            .map(|opt| opt.unwrap_or(0) as usize)
344            .collect()
345    }
346
347    fn get_numeric_column_f64(data: &DataFrame, column: &str) -> Vec<f64> {
348        let column_data = crate::data::get_numeric_column(data, column);
349        column_data
350            .into_iter()
351            .map(|opt| opt.unwrap_or(0.0) as f64)
352            .collect()
353    }
354
355    #[allow(clippy::too_many_arguments)]
356    fn create_ir_traces(
357        data: &DataFrame,
358        x: &str,
359        y: &str,
360        z: &str,
361        i: Option<&str>,
362        j: Option<&str>,
363        k: Option<&str>,
364        intensity: Option<&str>,
365        intensity_mode: Option<IntensityMode>,
366        color: Option<Rgb>,
367        color_bar: Option<&ColorBar>,
368        color_scale: Option<Palette>,
369        opacity: Option<f64>,
370        flat_shading: Option<bool>,
371        lighting: Option<&Lighting>,
372        light_position: Option<(i32, i32, i32)>,
373        delaunay_axis: Option<&str>,
374        contour: Option<bool>,
375    ) -> Vec<TraceIR> {
376        let ir = Self::build_mesh3d_ir(
377            data,
378            x,
379            y,
380            z,
381            i,
382            j,
383            k,
384            intensity,
385            intensity_mode,
386            color,
387            color_bar,
388            color_scale,
389            opacity,
390            flat_shading,
391            lighting,
392            light_position,
393            delaunay_axis,
394            contour,
395            None,
396        );
397        vec![TraceIR::Mesh3D(ir)]
398    }
399
400    #[allow(clippy::too_many_arguments)]
401    fn create_ir_traces_faceted(
402        data: &DataFrame,
403        x: &str,
404        y: &str,
405        z: &str,
406        i: Option<&str>,
407        j: Option<&str>,
408        k: Option<&str>,
409        intensity: Option<&str>,
410        intensity_mode: Option<IntensityMode>,
411        color: Option<Rgb>,
412        color_bar: Option<&ColorBar>,
413        color_scale: Option<Palette>,
414        opacity: Option<f64>,
415        flat_shading: Option<bool>,
416        lighting: Option<&Lighting>,
417        light_position: Option<(i32, i32, i32)>,
418        delaunay_axis: Option<&str>,
419        contour: Option<bool>,
420        facet_column: &str,
421        config: &FacetConfig,
422    ) -> Vec<TraceIR> {
423        const MAX_FACETS: usize = 8;
424
425        let facet_categories = crate::data::get_unique_groups(data, facet_column, config.sorter);
426
427        if facet_categories.len() > MAX_FACETS {
428            panic!(
429                "Facet column '{}' has {} unique values, but plotly.rs supports maximum {} 3D scenes",
430                facet_column,
431                facet_categories.len(),
432                MAX_FACETS
433            );
434        }
435
436        let mut traces = Vec::new();
437
438        for (facet_idx, facet_value) in facet_categories.iter().enumerate() {
439            let facet_data = crate::data::filter_data_by_group(data, facet_column, facet_value);
440            let scene = Self::get_scene_reference(facet_idx);
441
442            let ir = Self::build_mesh3d_ir(
443                &facet_data,
444                x,
445                y,
446                z,
447                i,
448                j,
449                k,
450                intensity,
451                intensity_mode,
452                color,
453                color_bar,
454                color_scale,
455                opacity,
456                flat_shading,
457                lighting,
458                light_position,
459                delaunay_axis,
460                contour,
461                Some(scene),
462            );
463
464            traces.push(TraceIR::Mesh3D(ir));
465        }
466
467        traces
468    }
469
470    #[allow(clippy::too_many_arguments)]
471    fn build_mesh3d_ir(
472        data: &DataFrame,
473        x: &str,
474        y: &str,
475        z: &str,
476        i: Option<&str>,
477        j: Option<&str>,
478        k: Option<&str>,
479        intensity: Option<&str>,
480        intensity_mode: Option<IntensityMode>,
481        color: Option<Rgb>,
482        color_bar: Option<&ColorBar>,
483        color_scale: Option<Palette>,
484        opacity: Option<f64>,
485        flat_shading: Option<bool>,
486        lighting: Option<&Lighting>,
487        light_position: Option<(i32, i32, i32)>,
488        delaunay_axis: Option<&str>,
489        contour: Option<bool>,
490        scene_ref: Option<String>,
491    ) -> Mesh3DIRStruct {
492        let x_data = ColumnData::Numeric(crate::data::get_numeric_column(data, x));
493        let y_data = ColumnData::Numeric(crate::data::get_numeric_column(data, y));
494        let z_data = ColumnData::Numeric(crate::data::get_numeric_column(data, z));
495
496        let i_data = if let (Some(i_col), Some(j_col), Some(k_col)) = (i, j, k) {
497            let _ = (j_col, k_col);
498            Some(ColumnData::Numeric(
499                Self::get_integer_column(data, i_col)
500                    .into_iter()
501                    .map(|v| Some(v as f32))
502                    .collect(),
503            ))
504        } else {
505            None
506        };
507
508        let j_data = if let (Some(_), Some(j_col), Some(_)) = (i, j, k) {
509            Some(ColumnData::Numeric(
510                Self::get_integer_column(data, j_col)
511                    .into_iter()
512                    .map(|v| Some(v as f32))
513                    .collect(),
514            ))
515        } else {
516            None
517        };
518
519        let k_data = if let (Some(_), Some(_), Some(k_col)) = (i, j, k) {
520            Some(ColumnData::Numeric(
521                Self::get_integer_column(data, k_col)
522                    .into_iter()
523                    .map(|v| Some(v as f32))
524                    .collect(),
525            ))
526        } else {
527            None
528        };
529
530        let intensity_data = intensity.map(|intensity_col| {
531            ColumnData::Numeric(
532                Self::get_numeric_column_f64(data, intensity_col)
533                    .into_iter()
534                    .map(|v| Some(v as f32))
535                    .collect(),
536            )
537        });
538
539        Mesh3DIRStruct {
540            x: x_data,
541            y: y_data,
542            z: z_data,
543            i: i_data,
544            j: j_data,
545            k: k_data,
546            intensity: intensity_data,
547            intensity_mode,
548            color_scale,
549            color_bar: color_bar.cloned(),
550            lighting: lighting.cloned(),
551            opacity,
552            color,
553            flat_shading,
554            light_position,
555            delaunay_axis: delaunay_axis.map(|s| s.to_string()),
556            contour,
557            scene_ref,
558        }
559    }
560
561    fn get_scene_reference(index: usize) -> String {
562        match index {
563            0 => "scene".to_string(),
564            1 => "scene2".to_string(),
565            2 => "scene3".to_string(),
566            3 => "scene4".to_string(),
567            4 => "scene5".to_string(),
568            5 => "scene6".to_string(),
569            6 => "scene7".to_string(),
570            7 => "scene8".to_string(),
571            _ => "scene".to_string(),
572        }
573    }
574}
575
576impl crate::Plot for Mesh3D {
577    fn ir_traces(&self) -> &[TraceIR] {
578        &self.traces
579    }
580
581    fn ir_layout(&self) -> &LayoutIR {
582        &self.layout
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use crate::Plot;
590    use polars::prelude::*;
591
592    fn sample_df() -> DataFrame {
593        df![
594            "x" => [0.0, 1.0, 0.5, 0.5],
595            "y" => [0.0, 0.0, 1.0, 0.5],
596            "z" => [0.0, 0.0, 0.0, 1.0]
597        ]
598        .unwrap()
599    }
600
601    #[test]
602    fn test_basic_one_trace() {
603        let df = sample_df();
604        let plot = Mesh3D::builder().data(&df).x("x").y("y").z("z").build();
605        assert_eq!(plot.ir_traces().len(), 1);
606    }
607
608    #[test]
609    fn test_trace_variant() {
610        let df = sample_df();
611        let plot = Mesh3D::builder().data(&df).x("x").y("y").z("z").build();
612        assert!(matches!(plot.ir_traces()[0], TraceIR::Mesh3D(_)));
613    }
614
615    #[test]
616    fn test_layout_no_cartesian_axes() {
617        let df = sample_df();
618        let plot = Mesh3D::builder().data(&df).x("x").y("y").z("z").build();
619        assert!(plot.ir_layout().axes_2d.is_none());
620    }
621
622    #[test]
623    fn test_layout_title() {
624        let df = sample_df();
625        let plot = Mesh3D::builder()
626            .data(&df)
627            .x("x")
628            .y("y")
629            .z("z")
630            .plot_title("Mesh")
631            .build();
632        assert!(plot.ir_layout().title.is_some());
633    }
634}