Skip to main content

runmat_plot/core/
viewport.rs

1use crate::core::{BoundingBox, Camera};
2use glam::Vec3;
3
4/// Compute the scale factor to convert pixel-sized geometry into data units.
5///
6/// Many plot style controls (e.g. `LineWidth`) are expressed in *pixels*, but some geometry
7/// generators (like thick polyline extrusion) operate in *data space*. This helper provides a
8/// conservative conversion factor based on the current data bounds and viewport size:
9///
10/// \[
11/// \text{data\_units\_per\_px} = \min\left(\frac{\Delta x}{w_{px}}, \frac{\Delta y}{h_{px}}\right)
12/// \]
13///
14/// Using the minimum axis scale avoids pathological over-thick extrusion when one data axis spans
15/// orders of magnitude more than the other (for example semilog/loglog plots). In those cases a
16/// `max(...)` conversion can turn a modest pixel width into a near-filled ribbon.
17pub fn data_units_per_px(bounds: &BoundingBox, viewport_px: (u32, u32)) -> f32 {
18    let (w_px, h_px) = viewport_px;
19    let w_px = w_px.max(1) as f32;
20    let h_px = h_px.max(1) as f32;
21
22    let x_range = (bounds.max.x - bounds.min.x).abs().max(1e-6);
23    let y_range = (bounds.max.y - bounds.min.y).abs().max(1e-6);
24
25    (x_range / w_px).min(y_range / h_px)
26}
27
28/// 3D variant of [`data_units_per_px`].
29///
30/// For `plot3` thick-line extrusion we convert pixel width to data units in the *screen plane*.
31/// Using Z-range in this conversion underestimates line width whenever Z span is much smaller
32/// than X/Y span, which makes 3D lines appear thinner than requested. So we use only the X and Y
33/// screen axes here.
34pub fn data_units_per_px_3d(bounds: &BoundingBox, viewport_px: (u32, u32)) -> f32 {
35    let (w_px, h_px) = viewport_px;
36    let w_px = w_px.max(1) as f32;
37    let h_px = h_px.max(1) as f32;
38
39    let x_range = (bounds.max.x - bounds.min.x).abs().max(1e-6);
40    let y_range = (bounds.max.y - bounds.min.y).abs().max(1e-6);
41
42    (x_range / w_px).min(y_range / h_px)
43}
44
45/// Camera-aware 3D conversion from pixels to data units.
46///
47/// This estimates world units per pixel at the scene center using the same
48/// camera fitting/view-angle model as 3D plot rendering, which keeps `LineWidth`
49/// in `plot3` visually closer to requested pixel thickness.
50pub fn data_units_per_px_3d_camera(
51    bounds: &BoundingBox,
52    viewport_px: (u32, u32),
53    view_angles_deg: Option<(f32, f32)>,
54) -> f32 {
55    let (w_px, h_px) = viewport_px;
56    let w = w_px.max(1) as f32;
57    let h = h_px.max(1) as f32;
58
59    let center = (bounds.min + bounds.max) * 0.5;
60    let mut camera = Camera::new();
61    camera.update_aspect_ratio((w / h).max(1e-6));
62    camera.fit_bounds(bounds.min, bounds.max);
63    if let Some((az, el)) = view_angles_deg {
64        camera.set_view_angles_deg(az, el);
65    }
66
67    let forward = (camera.target - camera.position).normalize_or_zero();
68    if forward.length_squared() <= 1e-8 {
69        return data_units_per_px_3d(bounds, viewport_px);
70    }
71    let right = forward.cross(camera.up).normalize_or_zero();
72    let up = right.cross(forward).normalize_or_zero();
73    if right.length_squared() <= 1e-8 || up.length_squared() <= 1e-8 {
74        return data_units_per_px_3d(bounds, viewport_px);
75    }
76
77    let unit = ((bounds.max - bounds.min).length() * 1e-3).max(1e-3);
78    let p0 = project_to_screen(&mut camera, center, w, h);
79    let px = project_to_screen(&mut camera, center + right * unit, w, h);
80    let py = project_to_screen(&mut camera, center + up * unit, w, h);
81    let (Some(p0), Some(px), Some(py)) = (p0, px, py) else {
82        return data_units_per_px_3d(bounds, viewport_px);
83    };
84
85    let px_per_unit_x = (px - p0).length() / unit;
86    let px_per_unit_y = (py - p0).length() / unit;
87    let px_per_unit = px_per_unit_x.min(px_per_unit_y).max(1e-6);
88    1.0 / px_per_unit
89}
90
91fn project_to_screen(camera: &mut Camera, pos: Vec3, w: f32, h: f32) -> Option<glam::Vec2> {
92    let vp = camera.view_proj_matrix();
93    let clip = vp * pos.extend(1.0);
94    if clip.w.abs() <= 1e-6 {
95        return None;
96    }
97    let ndc = clip.truncate() / clip.w;
98    if !ndc.is_finite() {
99        return None;
100    }
101    Some(glam::Vec2::new(
102        (ndc.x * 0.5 + 0.5) * w,
103        (1.0 - (ndc.y * 0.5 + 0.5)) * h,
104    ))
105}
106
107#[cfg(test)]
108mod tests {
109    use super::{data_units_per_px, data_units_per_px_3d, data_units_per_px_3d_camera};
110    use crate::core::BoundingBox;
111    use glam::Vec3;
112
113    #[test]
114    fn data_units_per_px_uses_min_2d_scale() {
115        let bounds = BoundingBox {
116            min: Vec3::new(0.0, 0.0, 0.0),
117            max: Vec3::new(100.0, 50.0, 0.0),
118        };
119        let scale = data_units_per_px(&bounds, (1000, 500));
120        assert!((scale - 0.1).abs() < 1e-6);
121    }
122
123    #[test]
124    fn data_units_per_px_3d_uses_screen_axes_scale() {
125        let bounds = BoundingBox {
126            min: Vec3::new(0.0, 0.0, 0.0),
127            max: Vec3::new(100.0, 50.0, 10.0),
128        };
129        let scale = data_units_per_px_3d(&bounds, (1000, 500));
130        // x: 0.1, y: 0.1 -> choose min => 0.1
131        assert!((scale - 0.1).abs() < 1e-6);
132    }
133
134    #[test]
135    fn data_units_per_px_3d_camera_returns_positive_finite_scale() {
136        let bounds = BoundingBox {
137            min: Vec3::new(-1.0, -1.0, -1.0),
138            max: Vec3::new(1.0, 1.0, 1.0),
139        };
140        let scale = data_units_per_px_3d_camera(&bounds, (1280, 720), Some((30.0, 20.0)));
141        assert!(scale.is_finite());
142        assert!(scale > 0.0);
143    }
144}