use egui::{Pos2, Rect, emath::RectTransform, pos2};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Scale {
Linear,
Log10,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum YAxis {
#[default]
Left,
Right,
Extra(usize),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AxisSide {
Left,
#[default]
Right,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Axis {
pub min: f64,
pub max: f64,
pub scale: Scale,
pub inverted: bool,
}
impl Axis {
pub fn linear(min: f64, max: f64) -> Self {
Self {
min,
max,
scale: Scale::Linear,
inverted: false,
}
}
pub fn norm(&self, v: f64) -> f64 {
let t = match self.scale {
Scale::Linear => (v - self.min) / (self.max - self.min),
Scale::Log10 => (v.log10() - self.min.log10()) / (self.max.log10() - self.min.log10()),
};
if self.inverted { 1.0 - t } else { t }
}
pub fn denorm(&self, t: f64) -> f64 {
let t = if self.inverted { 1.0 - t } else { t };
match self.scale {
Scale::Linear => self.min + t * (self.max - self.min),
Scale::Log10 => {
let lmin = self.min.log10();
let lmax = self.max.log10();
10f64.powf(lmin + t * (lmax - lmin))
}
}
}
fn effective_range(&self) -> (f64, f64) {
let (lo, hi) = match self.scale {
Scale::Linear => (self.min, self.max),
Scale::Log10 => (self.min.log10(), self.max.log10()),
};
if self.inverted { (hi, lo) } else { (lo, hi) }
}
}
pub fn keep_aspect_limits(
(x_min, x_max, y_min, y_max): (f64, f64, f64, f64),
area: Rect,
) -> (f64, f64, f64, f64) {
let (w, h) = (area.width() as f64, area.height() as f64);
let (x_span, y_span) = (x_max - x_min, y_max - y_min);
if w <= 0.0 || h <= 0.0 || x_span <= 0.0 || y_span <= 0.0 {
return (x_min, x_max, y_min, y_max);
}
let scale_x = x_span / w;
let scale_y = y_span / h;
if scale_x > scale_y {
let new_span = scale_x * h;
let cy = 0.5 * (y_min + y_max);
(x_min, x_max, cy - new_span / 2.0, cy + new_span / 2.0)
} else {
let new_span = scale_y * w;
let cx = 0.5 * (x_min + x_max);
(cx - new_span / 2.0, cx + new_span / 2.0, y_min, y_max)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Margins {
pub left: f32,
pub top: f32,
pub right: f32,
pub bottom: f32,
}
impl Margins {
pub const ZERO: Margins = Margins {
left: 0.0,
top: 0.0,
right: 0.0,
bottom: 0.0,
};
pub fn data_area(&self, full: Rect) -> Rect {
let w = full.width();
let h = full.height();
Rect::from_min_max(
pos2(full.left() + self.left * w, full.top() + self.top * h),
pos2(
full.right() - self.right * w,
full.bottom() - self.bottom * h,
),
)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Transform {
pub x: Axis,
pub y: Axis,
pub area: Rect,
}
impl Transform {
pub fn new(x_min: f64, x_max: f64, y_min: f64, y_max: f64, area: Rect) -> Self {
Self {
x: Axis::linear(x_min, x_max),
y: Axis::linear(y_min, y_max),
area,
}
}
pub fn with_axes(x: Axis, y: Axis, area: Rect) -> Self {
Self { x, y, area }
}
pub fn data_to_pixel(&self, x: f64, y: f64) -> Pos2 {
let (px, py) = self.data_to_pixel_f64(x, y);
pos2(px as f32, py as f32)
}
pub fn pixel_to_data(&self, p: Pos2) -> (f64, f64) {
self.pixel_to_data_f64(p.x as f64, p.y as f64)
}
fn data_to_pixel_f64(&self, x: f64, y: f64) -> (f64, f64) {
let (left, right) = (self.area.left() as f64, self.area.right() as f64);
let (top, bottom) = (self.area.top() as f64, self.area.bottom() as f64);
let tx = self.x.norm(x);
let ty = self.y.norm(y);
let px = left + tx * (right - left);
let py = bottom + ty * (top - bottom);
(px, py)
}
fn pixel_to_data_f64(&self, px: f64, py: f64) -> (f64, f64) {
let (left, right) = (self.area.left() as f64, self.area.right() as f64);
let (top, bottom) = (self.area.top() as f64, self.area.bottom() as f64);
let tx = (px - left) / (right - left);
let ty = (py - bottom) / (top - bottom);
(self.x.denorm(tx), self.y.denorm(ty))
}
pub fn rect_transform(&self) -> RectTransform {
let from = Rect::from_min_max(
pos2(self.x.min as f32, self.y.max as f32),
pos2(self.x.max as f32, self.y.min as f32),
);
RectTransform::from_to(from, self.area)
}
pub fn ortho_matrix(&self) -> [[f32; 4]; 4] {
let (ex0, ex1) = self.x.effective_range();
let (ey0, ey1) = self.y.effective_range();
let sx = (2.0 / (ex1 - ex0)) as f32;
let sy = (2.0 / (ey1 - ey0)) as f32;
let tx = (-(ex1 + ex0) / (ex1 - ex0)) as f32;
let ty = (-(ey1 + ey0) / (ey1 - ey0)) as f32;
[
[sx, 0.0, 0.0, 0.0],
[0.0, sy, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[tx, ty, 0.0, 1.0],
]
}
}
pub const FLOAT32_SAFE_MIN: f64 = -1e37;
pub const FLOAT32_SAFE_MAX: f64 = 1e37;
pub const FLOAT32_MINPOS: f64 = 1.1754943508222875e-38;
pub fn clamp_axis_limits(min: f64, max: f64, is_log: bool) -> (f64, f64) {
let lower = if is_log {
FLOAT32_MINPOS
} else {
FLOAT32_SAFE_MIN
};
let clip = |v: f64| -> f64 {
if v.is_nan() {
lower
} else {
v.clamp(lower, FLOAT32_SAFE_MAX)
}
};
let mut vmin = clip(min);
let mut vmax = clip(max);
if vmax < vmin {
std::mem::swap(&mut vmin, &mut vmax);
} else if vmax == vmin {
let v = vmin;
if v == 0.0 {
vmin = -0.1;
vmax = 0.1;
} else if v < 0.0 {
vmax = v * 0.9;
vmin = (v * 1.1).max(FLOAT32_SAFE_MIN);
} else {
vmax = (v * 1.1).min(FLOAT32_SAFE_MAX);
vmin = v * 0.9;
}
}
(vmin, vmax)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Transform {
let area = Rect::from_min_max(pos2(100.0, 20.0), pos2(420.0, 260.0));
Transform::new(-3.0, 5.0, 10.0, 50.0, area)
}
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
#[test]
fn corners_map_with_y_flip() {
let t = sample();
let (px, py) = t.data_to_pixel_f64(-3.0, 10.0);
assert!(
close(px, 100.0, 1e-9) && close(py, 260.0, 1e-9),
"{px},{py}"
);
let (px, py) = t.data_to_pixel_f64(5.0, 50.0);
assert!(close(px, 420.0, 1e-9) && close(py, 20.0, 1e-9), "{px},{py}");
let (px, py) = t.data_to_pixel_f64(1.0, 30.0);
assert!(
close(px, 260.0, 1e-9) && close(py, 140.0, 1e-9),
"{px},{py}"
);
}
#[test]
fn round_trip_is_identity_f64() {
let t = sample();
for &(x, y) in &[
(-3.0, 10.0),
(5.0, 50.0),
(1.0, 30.0),
(-1.25, 42.0),
(4.9, 11.1),
] {
let (px, py) = t.data_to_pixel_f64(x, y);
let (rx, ry) = t.pixel_to_data_f64(px, py);
assert!(
close(rx, x, 1e-9) && close(ry, y, 1e-9),
"{x},{y} -> {rx},{ry}"
);
}
}
#[test]
fn round_trip_through_pos2_is_close() {
let t = sample();
for &(x, y) in &[(-1.25, 42.0), (4.9, 11.1), (0.0, 25.0)] {
let (rx, ry) = t.pixel_to_data(t.data_to_pixel(x, y));
assert!(
close(rx, x, 1e-3) && close(ry, y, 1e-3),
"{x},{y} -> {rx},{ry}"
);
}
}
#[test]
fn rect_transform_agrees_with_data_to_pixel() {
let t = sample();
let rt = t.rect_transform();
for &(x, y) in &[(-3.0, 10.0), (5.0, 50.0), (1.0, 30.0)] {
let a = t.data_to_pixel(x, y);
let b = rt.transform_pos(pos2(x as f32, y as f32));
assert!(
(a.x - b.x).abs() <= 1e-3 && (a.y - b.y).abs() <= 1e-3,
"{a:?} vs {b:?}"
);
}
}
#[test]
fn ortho_matrix_maps_limits_to_ndc() {
let t = sample();
let m = t.ortho_matrix();
let apply = |x: f32, y: f32| -> (f32, f32) {
let cx = m[0][0] * x + m[1][0] * y + m[3][0];
let cy = m[0][1] * x + m[1][1] * y + m[3][1];
(cx, cy)
};
let approx = |a: f32, b: f32| (a - b).abs() <= 1e-5;
let (cx, cy) = apply(-3.0, 10.0);
assert!(approx(cx, -1.0) && approx(cy, -1.0), "{cx},{cy}");
let (cx, cy) = apply(5.0, 50.0);
assert!(approx(cx, 1.0) && approx(cy, 1.0), "{cx},{cy}");
let (cx, cy) = apply(1.0, 30.0);
assert!(approx(cx, 0.0) && approx(cy, 0.0), "{cx},{cy}");
}
#[test]
fn margins_inset_full_rect() {
let full = Rect::from_min_max(pos2(0.0, 0.0), pos2(200.0, 100.0));
let m = Margins {
left: 0.1,
top: 0.2,
right: 0.05,
bottom: 0.0,
};
let area = m.data_area(full);
assert_eq!(area.left(), 20.0);
assert_eq!(area.top(), 20.0);
assert_eq!(area.right(), 190.0);
assert_eq!(area.bottom(), 100.0);
}
#[test]
fn axis_norm_denorm_round_trip_all_scales() {
let axes = [
Axis::linear(-3.0, 5.0),
Axis {
min: -3.0,
max: 5.0,
scale: Scale::Linear,
inverted: true,
},
Axis {
min: 1.0,
max: 1000.0,
scale: Scale::Log10,
inverted: false,
},
Axis {
min: 1.0,
max: 1000.0,
scale: Scale::Log10,
inverted: true,
},
];
for axis in axes {
for &v in &[axis.min, axis.max, axis.denorm(0.5), axis.denorm(0.27)] {
let t = axis.norm(v);
let back = axis.denorm(t);
assert!((back - v).abs() <= 1e-9 * v.abs().max(1.0), "{axis:?}: {v}");
}
}
}
#[test]
fn inverted_axis_flips_endpoints() {
let a = Axis::linear(0.0, 10.0);
let b = Axis {
inverted: true,
..a
};
assert!(close(a.norm(0.0), 0.0, 1e-12) && close(a.norm(10.0), 1.0, 1e-12));
assert!(close(b.norm(0.0), 1.0, 1e-12) && close(b.norm(10.0), 0.0, 1e-12));
}
#[test]
fn log_axis_maps_decades_evenly() {
let a = Axis {
min: 1.0,
max: 1000.0,
scale: Scale::Log10,
inverted: false,
};
assert!(close(a.norm(1.0), 0.0, 1e-12));
assert!(close(a.norm(10.0), 1.0 / 3.0, 1e-12));
assert!(close(a.norm(100.0), 2.0 / 3.0, 1e-12));
assert!(close(a.norm(1000.0), 1.0, 1e-12));
}
#[test]
fn keep_aspect_equalizes_units_per_pixel() {
let area = Rect::from_min_max(pos2(0.0, 0.0), pos2(200.0, 100.0));
let (x0, x1, y0, y1) = keep_aspect_limits((0.0, 10.0, 0.0, 10.0), area);
assert!(close(y0, 0.0, 1e-12) && close(y1, 10.0, 1e-12));
assert!(close(x0, -5.0, 1e-9) && close(x1, 15.0, 1e-9), "{x0},{x1}");
let sx = (x1 - x0) / area.width() as f64;
let sy = (y1 - y0) / area.height() as f64;
assert!(close(sx, sy, 1e-9), "{sx} vs {sy}");
}
#[test]
fn keep_aspect_is_idempotent_for_fixed_area() {
let area = Rect::from_min_max(pos2(0.0, 0.0), pos2(150.0, 300.0));
let once = keep_aspect_limits((0.0, 4.0, -2.0, 2.0), area);
let twice = keep_aspect_limits(once, area);
assert!(
close(once.0, twice.0, 1e-9)
&& close(once.1, twice.1, 1e-9)
&& close(once.2, twice.2, 1e-9)
&& close(once.3, twice.3, 1e-9),
"{once:?} vs {twice:?}"
);
}
#[test]
fn keep_aspect_noop_on_degenerate_inputs() {
let zero = Rect::from_min_max(pos2(0.0, 0.0), pos2(0.0, 0.0));
assert_eq!(
keep_aspect_limits((0.0, 1.0, 0.0, 1.0), zero),
(0.0, 1.0, 0.0, 1.0)
);
let area = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0));
assert_eq!(
keep_aspect_limits((5.0, 5.0, 0.0, 1.0), area),
(5.0, 5.0, 0.0, 1.0)
);
}
#[test]
fn inverted_ortho_flips_ndc() {
let area = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0));
let t = Transform::with_axes(
Axis {
inverted: true,
..Axis::linear(0.0, 10.0)
},
Axis::linear(0.0, 10.0),
area,
);
let m = t.ortho_matrix();
let ndc_x = |x: f32| m[0][0] * x + m[3][0];
assert!((ndc_x(0.0) - 1.0).abs() <= 1e-5);
assert!((ndc_x(10.0) + 1.0).abs() <= 1e-5);
}
}