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#[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}