1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
//! Shared types for 3D plot types (Scatter3D, Surface3D).
/// Viewing angles for 3D projection.
#[derive(Debug, Clone, Copy)]
pub struct View3D {
/// Azimuth angle in degrees (rotation around Z-axis). Default: -60.
pub azimuth: f64,
/// Elevation angle in degrees (rotation from XY-plane). Default: 30.
pub elevation: f64,
}
impl Default for View3D {
fn default() -> Self {
Self {
azimuth: -60.0,
elevation: 30.0,
}
}
}
impl View3D {
/// Compute the rotation matrix row 1 (depth axis) for these view angles.
pub(crate) fn depth_row(&self) -> [f64; 3] {
let az = self.azimuth.to_radians();
let el = self.elevation.to_radians();
[az.sin() * el.cos(), az.cos() * el.cos(), -el.sin()]
}
/// Find the floor-face corner closest to the viewer (smallest depth).
/// Returns the normalized (x, y) signs of that corner, e.g. (0.5, -0.5).
/// This is the "open front corner" where axes originate.
///
/// For positive elevation the floor is z=-0.5; for negative elevation
/// (viewing from below) the floor is z=+0.5.
pub fn front_bottom_corner(&self) -> (f64, f64) {
let row1 = self.depth_row();
let floor_z = if self.elevation >= 0.0 { -0.5 } else { 0.5 };
let mut best_x = -0.5_f64;
let mut best_y = -0.5_f64;
let mut best_d = f64::INFINITY;
for &nx in &[-0.5_f64, 0.5] {
for &ny in &[-0.5_f64, 0.5] {
let d = row1[0] * nx + row1[1] * ny + row1[2] * floor_z;
if d < best_d {
best_d = d;
best_x = nx;
best_y = ny;
}
}
}
(best_x, best_y)
}
/// Derive which screen side the Z axis should appear on for this view.
///
/// When the front corner is at `fc_x >= 0` (e.g. default azimuth -60°),
/// the rightmost back edge is the natural Z axis position. When the view
/// is mirrored (`fc_x < 0`, e.g. azimuth +60°), the leftmost edge reads
/// more naturally. This matches matplotlib's default behaviour across all
/// azimuths without the user needing to know about it.
pub fn auto_z_axis_right(&self) -> bool {
self.front_bottom_corner().0 >= 0.0
}
}
/// Axis-aligned bounding box for 3D data.
#[derive(Debug, Clone, Copy)]
pub struct DataRanges3D {
pub x: (f64, f64),
pub y: (f64, f64),
pub z: (f64, f64),
}
/// Shared configuration for the 3D open-box wireframe, grid, and axes.
/// Embedded by both `Scatter3DPlot` and `Surface3DPlot`.
#[derive(Clone, Debug)]
pub struct Box3DConfig {
pub view: View3D,
pub x_label: Option<String>,
pub y_label: Option<String>,
pub z_label: Option<String>,
pub show_grid: bool,
pub show_box: bool,
pub grid_lines: usize,
/// Override Z-axis side: `Some(true)` = right, `Some(false)` = left, `None` = auto.
/// Auto derives the side from the view angles — right when `fc_x >= 0`, left otherwise.
pub z_axis_right: Option<bool>,
}
impl Default for Box3DConfig {
fn default() -> Self {
Self {
view: View3D::default(),
x_label: None,
y_label: None,
z_label: None,
show_grid: true,
show_box: true,
grid_lines: 5,
z_axis_right: None,
}
}
}
impl Box3DConfig {
pub fn with_azimuth(mut self, deg: f64) -> Self {
self.view.azimuth = deg;
self
}
pub fn with_elevation(mut self, deg: f64) -> Self {
self.view.elevation = deg;
self
}
pub fn with_view(mut self, v: View3D) -> Self {
self.view = v;
self
}
pub fn with_x_label<S: Into<String>>(mut self, l: S) -> Self {
self.x_label = Some(l.into());
self
}
pub fn with_y_label<S: Into<String>>(mut self, l: S) -> Self {
self.y_label = Some(l.into());
self
}
pub fn with_z_label<S: Into<String>>(mut self, l: S) -> Self {
self.z_label = Some(l.into());
self
}
pub fn with_no_grid(mut self) -> Self {
self.show_grid = false;
self
}
pub fn with_no_box(mut self) -> Self {
self.show_box = false;
self
}
pub fn with_grid_lines(mut self, n: usize) -> Self {
self.grid_lines = n;
self
}
/// Force the Z axis to a specific side. Pass `true` for right, `false` for left.
/// Call `.with_z_axis_auto()` to restore automatic placement.
pub fn with_z_axis_right(mut self, r: bool) -> Self {
self.z_axis_right = Some(r);
self
}
/// Use automatic Z-axis placement based on the view angles (default).
pub fn with_z_axis_auto(mut self) -> Self {
self.z_axis_right = None;
self
}
}