use glam::Vec3;
use crate::color::Color;
use crate::scene::style::GridSettings;
const MAX_TICKS_PER_AXIS: usize = 64;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AxisKind {
X,
Y,
Z,
}
impl AxisKind {
pub fn direction(self) -> Vec3 {
match self {
AxisKind::X => Vec3::X,
AxisKind::Y => Vec3::Y,
AxisKind::Z => Vec3::Z,
}
}
fn index(self) -> usize {
match self {
AxisKind::X => 0,
AxisKind::Y => 1,
AxisKind::Z => 2,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub enum AxisRange {
#[default]
World,
Linear {
world_span: Option<(f32, f32)>,
data: (f32, f32),
},
}
impl AxisRange {
fn value_at(self, world_coord: f32, axis_span: (f32, f32)) -> f32 {
match self {
AxisRange::World => world_coord,
AxisRange::Linear { world_span, data } => {
let (ws0, ws1) = world_span.unwrap_or(axis_span);
let span = ws1 - ws0;
if span.abs() <= f32::EPSILON {
data.0
} else {
let t = (world_coord - ws0) / span;
data.0 + t * (data.1 - data.0)
}
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub enum TickPolicy {
#[default]
FromGrid,
Count(u32),
Step(f32),
}
#[derive(Clone, Debug, PartialEq, Default)]
pub enum TickFormat {
#[default]
Auto,
Decimal(u8),
Integer,
Scientific,
Percent,
Suffix(String),
}
impl TickFormat {
pub fn format(&self, v: f32) -> String {
match self {
TickFormat::Auto => format_auto(v),
TickFormat::Decimal(p) => format!("{:.*}", *p as usize, v),
TickFormat::Integer => format!("{}", v.round() as i64),
TickFormat::Scientific => format!("{:e}", v),
TickFormat::Percent => format!("{}%", format_auto(v * 100.0)),
TickFormat::Suffix(s) => format!("{}{s}", format_auto(v)),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct AxisSpec {
pub visible: bool,
pub title: Option<String>,
pub range: AxisRange,
pub ticks: TickPolicy,
pub format: TickFormat,
}
impl Default for AxisSpec {
fn default() -> Self {
Self {
visible: true,
title: None,
range: AxisRange::World,
ticks: TickPolicy::FromGrid,
format: TickFormat::Auto,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Axes {
pub x: AxisSpec,
pub y: AxisSpec,
pub z: AxisSpec,
pub label_color: Color,
pub label_size: f32,
}
impl Default for Axes {
fn default() -> Self {
Self {
x: AxisSpec::default(),
y: AxisSpec::default(),
z: AxisSpec::default(),
label_color: Color::srgb_u8a(208, 214, 226, 235),
label_size: 11.0,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct AxisLabel {
pub world: Vec3,
pub text: String,
}
impl Axes {
pub fn titles(x: impl Into<String>, y: impl Into<String>, z: impl Into<String>) -> Self {
let mut axes = Axes::default();
axes.x.title = Some(x.into());
axes.y.title = Some(y.into());
axes.z.title = Some(z.into());
axes
}
fn spec(&self, kind: AxisKind) -> &AxisSpec {
match kind {
AxisKind::X => &self.x,
AxisKind::Y => &self.y,
AxisKind::Z => &self.z,
}
}
pub fn labels(&self, grid: &GridSettings) -> Vec<AxisLabel> {
let mut out = Vec::new();
let spans = grid.axis_spans();
for kind in [AxisKind::X, AxisKind::Y, AxisKind::Z] {
let spec = self.spec(kind);
if !spec.visible {
continue;
}
let dir = kind.direction();
let span = spans[kind.index()];
for coord in tick_coords(spec.ticks, span, grid.spacing) {
let value = spec.range.value_at(coord, span);
out.push(AxisLabel {
world: dir * coord,
text: spec.format.format(value),
});
}
if let Some(title) = &spec.title {
let (lo, hi) = span;
let beyond = hi + grid.spacing.abs().max((hi - lo) * 0.1);
out.push(AxisLabel {
world: dir * beyond,
text: title.clone(),
});
}
}
out
}
}
fn tick_coords(policy: TickPolicy, span: (f32, f32), grid_spacing: f32) -> Vec<f32> {
let (lo, hi) = (span.0.min(span.1), span.0.max(span.1));
if hi - lo <= 0.0 {
return Vec::new();
}
let mut coords = Vec::new();
match policy {
TickPolicy::FromGrid | TickPolicy::Step(_) => {
let step = match policy {
TickPolicy::Step(s) => s,
_ => grid_spacing,
};
if step <= 0.0 {
return Vec::new();
}
let k0 = (lo / step).ceil() as i64;
let k1 = (hi / step).floor() as i64;
for k in k0..=k1 {
if k == 0 {
continue; }
coords.push(k as f32 * step);
if coords.len() >= 2 * MAX_TICKS_PER_AXIS {
break;
}
}
}
TickPolicy::Count(n) => {
let n = (n as usize).min(MAX_TICKS_PER_AXIS);
if n >= 2 {
let width = hi - lo;
for i in 0..n {
let t = i as f32 / (n - 1) as f32;
let coord = lo + t * width;
if coord.abs() > width * 1e-3 {
coords.push(coord);
}
}
}
}
}
coords
}
fn format_auto(v: f32) -> String {
if (v - v.round()).abs() < 1e-4 {
format!("{}", v.round() as i64)
} else {
let s = format!("{v:.3}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn grid(spacing: f32, extent: f32) -> GridSettings {
GridSettings {
spacing,
extent,
..GridSettings::default()
}
}
#[test]
fn from_grid_ticks_skip_origin_and_track_spacing() {
let coords = tick_coords(TickPolicy::FromGrid, (-10.0, 10.0), 5.0);
assert_eq!(coords, vec![-10.0, -5.0, 5.0, 10.0]);
}
#[test]
fn count_ticks_are_evenly_spaced() {
let coords = tick_coords(TickPolicy::Count(5), (-10.0, 10.0), 1.0);
assert_eq!(coords, vec![-10.0, -5.0, 5.0, 10.0]);
}
#[test]
fn one_sided_span_ticks_stay_within_bounds() {
let coords = tick_coords(TickPolicy::FromGrid, (0.0, 100.0), 25.0);
assert_eq!(coords, vec![25.0, 50.0, 75.0, 100.0]);
assert!(coords.iter().all(|&c| c > 0.0 && c <= 100.0));
let counted = tick_coords(TickPolicy::Count(5), (0.0, 100.0), 1.0);
assert_eq!(counted, vec![25.0, 50.0, 75.0, 100.0]);
}
#[test]
fn degenerate_policies_make_no_ticks() {
assert!(tick_coords(TickPolicy::Step(0.0), (-10.0, 10.0), 1.0).is_empty());
assert!(tick_coords(TickPolicy::FromGrid, (0.0, 0.0), 1.0).is_empty());
assert!(tick_coords(TickPolicy::Count(1), (-10.0, 10.0), 1.0).is_empty());
}
#[test]
fn tick_count_is_capped() {
let coords = tick_coords(TickPolicy::Step(0.001), (-10.0, 10.0), 1.0);
assert!(coords.len() <= 2 * MAX_TICKS_PER_AXIS);
}
#[test]
fn world_range_labels_the_coordinate() {
let axes = Axes::default();
let labels = axes.labels(&grid(5.0, 10.0));
let five = labels
.iter()
.find(|l| (l.world - Vec3::new(5.0, 0.0, 0.0)).length() < 1e-4)
.unwrap();
assert_eq!(five.text, "5");
}
#[test]
fn linear_range_remaps_world_to_data() {
let r = AxisRange::Linear {
world_span: None,
data: (0.0, 100.0),
};
assert_eq!(r.value_at(5.0, (-10.0, 10.0)), 75.0);
assert_eq!(r.value_at(-10.0, (-10.0, 10.0)), 0.0);
assert_eq!(r.value_at(10.0, (-10.0, 10.0)), 100.0);
}
#[test]
fn titles_sit_past_the_axis_and_are_included() {
let axes = Axes::titles("Xs", "Ys", "Zs");
let labels = axes.labels(&grid(1.0, 10.0));
let x_title = labels.iter().find(|l| l.text == "Xs").unwrap();
assert!(x_title.world.x > 10.0, "title sits past the positive end");
assert_eq!(x_title.world.y, 0.0);
assert_eq!(x_title.world.z, 0.0);
assert!(labels.iter().any(|l| l.text == "Ys"));
assert!(labels.iter().any(|l| l.text == "Zs"));
}
#[test]
fn per_axis_bounds_clip_labels_and_title() {
use crate::scene::style::AxisBounds;
let mut g = grid(25.0, 100.0);
g.bounds = AxisBounds {
y: Some((0.0, 100.0)),
..Default::default()
};
let axes = Axes::titles("X", "Y", "Z");
let labels = axes.labels(&g);
let y_ticks: Vec<f32> = labels
.iter()
.filter(|l| l.world.x == 0.0 && l.world.z == 0.0 && l.text != "Y")
.map(|l| l.world.y)
.collect();
assert_eq!(y_ticks, vec![25.0, 50.0, 75.0, 100.0]);
let y_title = labels.iter().find(|l| l.text == "Y").unwrap();
assert!(
(y_title.world.y - 125.0).abs() < 1e-3,
"title past max, got {:?}",
y_title.world
);
assert!(labels.iter().any(|l| l.world.x < 0.0));
}
#[test]
fn invisible_axis_emits_nothing() {
let mut axes = Axes::titles("Xs", "Ys", "Zs");
axes.y.visible = false;
let labels = axes.labels(&grid(1.0, 10.0));
assert!(
!labels
.iter()
.any(|l| l.world.x == 0.0 && l.world.y != 0.0 && l.text == "Ys")
);
assert!(!labels.iter().any(|l| l.world.y.abs() > 1e-6));
}
#[test]
fn format_variants_render_expected_text() {
assert_eq!(TickFormat::Auto.format(5.0), "5");
assert_eq!(TickFormat::Auto.format(2.5), "2.5");
assert_eq!(TickFormat::Decimal(2).format(1.23456), "1.23");
assert_eq!(TickFormat::Integer.format(3.7), "4");
assert_eq!(TickFormat::Percent.format(0.25), "25%");
assert_eq!(TickFormat::Suffix(" px".into()).format(12.0), "12 px");
}
}