use glam::{Mat4, Vec2, Vec3};
use serde::{Deserialize, Serialize};
use viewport_lib::{LabelItem, PolylineItem};
use crate::domain::Domain;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Axis3 {
X,
Y,
Z,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AxisConfig {
pub show_box: bool,
pub show_labels: bool,
pub show_ticks: bool,
pub show_grid: bool,
pub labels: [Option<String>; 3],
pub tick_count: [u32; 3],
pub axis_colours: [[f32; 4]; 3],
pub tick_colour: [f32; 4],
}
impl Default for AxisConfig {
fn default() -> Self {
let dim = [0.7, 0.7, 0.7, 1.0];
Self {
show_box: true,
show_labels: true,
show_ticks: true,
show_grid: false,
labels: [
Some("x".to_string()),
Some("y".to_string()),
Some("z".to_string()),
],
tick_count: [5, 5, 5],
axis_colours: [
[0.9, 0.2, 0.2, 1.0], [0.2, 0.9, 0.2, 1.0], [0.2, 0.2, 0.9, 1.0], ],
tick_colour: dim,
}
}
}
pub fn build_axis_lines(domain: &Domain) -> [([f32; 3], [f32; 3]); 3] {
let x0 = *domain.x.start() as f32;
let x1 = *domain.x.end() as f32;
let y0 = *domain.y.start() as f32;
let y1 = *domain.y.end() as f32;
let z0 = *domain.z.start() as f32;
let z1 = *domain.z.end() as f32;
[
([x0, 0.0, 0.0], [x1, 0.0, 0.0]), ([0.0, y0, 0.0], [0.0, y1, 0.0]), ([0.0, 0.0, z0], [0.0, 0.0, z1]), ]
}
const MAX_STUB_LENGTH: f32 = 0.3;
const MIN_STUB_LENGTH: f32 = 1e-4;
const MAX_LABEL_OFFSET: f32 = 0.6;
const MIN_LABEL_OFFSET: f32 = 5e-4;
const TARGET_TICK_NDC: f32 = 0.025;
const TARGET_LABEL_OFFSET_NDC: f32 = 0.035;
fn axis_span(domain: &Domain, axis: Axis3) -> f32 {
match axis {
Axis3::X => (*domain.x.end() - *domain.x.start()) as f32,
Axis3::Y => (*domain.y.end() - *domain.y.start()) as f32,
Axis3::Z => (*domain.z.end() - *domain.z.start()) as f32,
}
.abs()
}
fn tick_step_hint(domain: &Domain, axis: Axis3, tick_positions: &[(f64, String)]) -> f32 {
let mut deltas: Vec<f32> = tick_positions
.windows(2)
.map(|pair| (pair[1].0 - pair[0].0).abs() as f32)
.filter(|d| *d > 1e-6)
.collect();
if deltas.is_empty() {
let span = axis_span(domain, axis);
return (span / 5.0).max(MIN_STUB_LENGTH);
}
deltas.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
deltas[deltas.len() / 2]
}
fn stub_length_for_axis(domain: &Domain, axis: Axis3, tick_positions: &[(f64, String)]) -> f32 {
(tick_step_hint(domain, axis, tick_positions) * 0.18).clamp(MIN_STUB_LENGTH, MAX_STUB_LENGTH)
}
fn label_offset_for_axis(domain: &Domain, axis: Axis3, tick_positions: &[(f64, String)]) -> f32 {
(stub_length_for_axis(domain, axis, tick_positions) * 1.4)
.clamp(MIN_LABEL_OFFSET, MAX_LABEL_OFFSET)
}
fn stub_direction(axis: Axis3) -> Vec3 {
match axis {
Axis3::X => Vec3::NEG_Y,
Axis3::Y => Vec3::NEG_X,
Axis3::Z => Vec3::NEG_Y,
}
}
fn axis_point(axis: Axis3, pos: f64) -> Vec3 {
let p = pos as f32;
match axis {
Axis3::X => Vec3::new(p, 0.0, 0.0),
Axis3::Y => Vec3::new(0.0, p, 0.0),
Axis3::Z => Vec3::new(0.0, 0.0, p),
}
}
fn project_to_ndc_xy(vp: &Mat4, world: Vec3) -> Option<Vec2> {
let clip = *vp * world.extend(1.0);
if clip.w.abs() < 1e-6 || clip.z < -clip.w || clip.z > clip.w {
return None;
}
let inv_w = 1.0 / clip.w;
Some(Vec2::new(clip.x * inv_w, clip.y * inv_w))
}
fn projected_world_extent(
vp: &Mat4,
origin: Vec3,
direction: Vec3,
target_ndc: f32,
) -> Option<f32> {
let base = project_to_ndc_xy(vp, origin)?;
let mut lo = MIN_STUB_LENGTH;
let mut hi = MAX_STUB_LENGTH;
let end_hi = project_to_ndc_xy(vp, origin + direction * hi)?;
if (end_hi - base).length() <= target_ndc {
return Some(hi);
}
for _ in 0..20 {
let mid = 0.5 * (lo + hi);
let end = project_to_ndc_xy(vp, origin + direction * mid)?;
if (end - base).length() < target_ndc {
lo = mid;
} else {
hi = mid;
}
}
Some(hi)
}
fn projected_stub_length(
domain: &Domain,
vp: Option<&Mat4>,
axis: Axis3,
tick_positions: &[(f64, String)],
pos: f64,
) -> f32 {
let fallback = stub_length_for_axis(domain, axis, tick_positions);
let Some(vp) = vp else { return fallback };
projected_world_extent(
vp,
axis_point(axis, pos),
stub_direction(axis),
TARGET_TICK_NDC,
)
.unwrap_or(fallback)
.clamp(MIN_STUB_LENGTH, MAX_STUB_LENGTH)
}
fn projected_label_offset(
domain: &Domain,
vp: Option<&Mat4>,
axis: Axis3,
tick_positions: &[(f64, String)],
pos: f64,
) -> f32 {
let fallback = label_offset_for_axis(domain, axis, tick_positions);
let Some(vp) = vp else { return fallback };
projected_world_extent(
vp,
axis_point(axis, pos),
stub_direction(axis),
TARGET_LABEL_OFFSET_NDC,
)
.unwrap_or(fallback)
.clamp(MIN_LABEL_OFFSET, MAX_LABEL_OFFSET)
}
pub fn build_tick_stubs(
domain: &Domain,
axis: Axis3,
tick_positions: &[(f64, String)],
) -> Vec<([f32; 3], [f32; 3])> {
build_tick_stubs_projected(domain, None, axis, tick_positions)
}
pub fn build_tick_stubs_projected(
domain: &Domain,
vp: Option<&Mat4>,
axis: Axis3,
tick_positions: &[(f64, String)],
) -> Vec<([f32; 3], [f32; 3])> {
tick_positions
.iter()
.map(|(pos, _)| {
let p = *pos as f32;
let stub_length = projected_stub_length(domain, vp, axis, tick_positions, *pos);
match axis {
Axis3::X => ([p, 0.0, 0.0], [p, -stub_length, 0.0]),
Axis3::Y => ([0.0, p, 0.0], [-stub_length, p, 0.0]),
Axis3::Z => ([0.0, 0.0, p], [0.0, -stub_length, p]),
}
})
.collect()
}
pub fn build_axis_polyline(
domain: &Domain,
config: &AxisConfig,
ticks_per_axis: &[Vec<(f64, String)>; 3],
) -> Vec<PolylineItem> {
build_axis_polyline_projected(domain, config, ticks_per_axis, None)
}
pub fn build_axis_polyline_projected(
domain: &Domain,
config: &AxisConfig,
ticks_per_axis: &[Vec<(f64, String)>; 3],
vp: Option<&Mat4>,
) -> Vec<PolylineItem> {
let mut items = Vec::new();
let axes = [Axis3::X, Axis3::Y, Axis3::Z];
let lines = build_axis_lines(domain);
for (i, axis) in axes.iter().enumerate() {
let colour = config.axis_colours[i];
if config.show_box {
let (a, b) = lines[i];
let mut item = PolylineItem::default();
item.positions = vec![a, b];
item.scalars = Vec::new();
item.strip_lengths = vec![2];
item.scalar_range = None;
item.colourmap_id = None;
item.default_colour = colour;
item.line_width = 1.5;
items.push(item);
}
if config.show_ticks {
let stubs = build_tick_stubs_projected(domain, vp, *axis, &ticks_per_axis[i]);
if !stubs.is_empty() {
let mut positions: Vec<[f32; 3]> = Vec::with_capacity(stubs.len() * 2);
let mut strip_lengths: Vec<u32> = Vec::with_capacity(stubs.len());
for (a, b) in stubs {
positions.push(a);
positions.push(b);
strip_lengths.push(2);
}
let mut item = PolylineItem::default();
item.positions = positions;
item.scalars = Vec::new();
item.strip_lengths = strip_lengths;
item.scalar_range = None;
item.colourmap_id = None;
item.default_colour = colour;
item.line_width = 1.0;
items.push(item);
}
}
}
items
}
pub(crate) fn tick_label_anchor(
domain: &Domain,
vp: Option<&Mat4>,
axis: Axis3,
tick_positions: &[(f64, String)],
pos: f64,
) -> Vec3 {
let offset = projected_label_offset(domain, vp, axis, tick_positions, pos);
let p = pos as f32;
match axis {
Axis3::X => Vec3::new(p, -offset, 0.0),
Axis3::Y => Vec3::new(-offset, p, 0.0),
Axis3::Z => Vec3::new(-offset, 0.0, p),
}
}
pub fn build_axis_labels(
domain: &Domain,
config: &AxisConfig,
ticks_per_axis: &[Vec<(f64, String)>; 3],
) -> Vec<LabelItem> {
build_axis_labels_projected(domain, config, ticks_per_axis, None)
}
pub fn build_axis_labels_projected(
domain: &Domain,
config: &AxisConfig,
ticks_per_axis: &[Vec<(f64, String)>; 3],
vp: Option<&Mat4>,
) -> Vec<LabelItem> {
let x1 = *domain.x.end() as f32;
let y1 = *domain.y.end() as f32;
let z1 = *domain.z.end() as f32;
let tc = config.tick_colour;
let mut labels: Vec<LabelItem> = Vec::new();
if config.show_labels {
let mut origin_labelled = false;
for (pos, text) in &ticks_per_axis[0] {
if *pos == 0.0 {
if origin_labelled {
continue;
}
origin_labelled = true;
}
let mut lbl = LabelItem::default();
lbl.world_anchor =
Some(tick_label_anchor(domain, vp, Axis3::X, &ticks_per_axis[0], *pos).to_array());
lbl.text = text.clone();
lbl.colour = tc;
lbl.font_size = 11.0;
labels.push(lbl);
}
for (pos, text) in &ticks_per_axis[1] {
if *pos == 0.0 {
if origin_labelled {
continue;
}
origin_labelled = true;
}
let mut lbl = LabelItem::default();
lbl.world_anchor =
Some(tick_label_anchor(domain, vp, Axis3::Y, &ticks_per_axis[1], *pos).to_array());
lbl.text = text.clone();
lbl.colour = tc;
lbl.font_size = 11.0;
labels.push(lbl);
}
for (pos, text) in &ticks_per_axis[2] {
if *pos == 0.0 {
if origin_labelled {
continue;
}
origin_labelled = true;
}
let mut lbl = LabelItem::default();
lbl.world_anchor =
Some(tick_label_anchor(domain, vp, Axis3::Z, &ticks_per_axis[2], *pos).to_array());
lbl.text = text.clone();
lbl.colour = tc;
lbl.font_size = 11.0;
labels.push(lbl);
}
}
let name_positions = [
(
config.labels[0].as_deref(),
Vec3::new(
x1 + projected_label_offset(
domain,
vp,
Axis3::X,
&ticks_per_axis[0],
*domain.x.end(),
),
0.0,
0.0,
),
),
(
config.labels[1].as_deref(),
Vec3::new(
0.0,
y1 + projected_label_offset(
domain,
vp,
Axis3::Y,
&ticks_per_axis[1],
*domain.y.end(),
),
0.0,
),
),
(
config.labels[2].as_deref(),
Vec3::new(
0.0,
0.0,
z1 + projected_label_offset(
domain,
vp,
Axis3::Z,
&ticks_per_axis[2],
*domain.z.end(),
),
),
),
];
if config.show_labels {
for (name_opt, world_pos) in &name_positions {
let Some(name) = name_opt else { continue };
let mut lbl = LabelItem::default();
lbl.world_anchor = Some(world_pos.to_array());
lbl.text = name.to_string();
lbl.colour = tc;
lbl.font_size = 13.0;
labels.push(lbl);
}
}
labels
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::Domain;
fn default_domain() -> Domain {
Domain::default() }
#[test]
fn axis_lines_has_three() {
let lines = build_axis_lines(&default_domain());
assert_eq!(lines.len(), 3, "must produce exactly 3 axis lines");
assert_eq!(lines[0].0[1], 0.0);
assert_eq!(lines[0].0[2], 0.0);
}
#[test]
fn tick_stubs_match_count() {
let domain = default_domain();
let ticks: Vec<(f64, String)> = vec![
(-10.0, "-10".to_string()),
(-5.0, "-5".to_string()),
(0.0, "0".to_string()),
(5.0, "5".to_string()),
(10.0, "10".to_string()),
];
let stubs = build_tick_stubs(&domain, Axis3::X, &ticks);
assert_eq!(stubs.len(), ticks.len());
}
#[test]
fn tick_stubs_x_direction() {
let domain = default_domain();
let ticks = vec![(5.0_f64, "5".to_string())];
let stubs = build_tick_stubs(&domain, Axis3::X, &ticks);
let (start, end) = stubs[0];
assert!((start[0] - 5.0).abs() < 1e-6);
assert!((start[1] - 0.0).abs() < 1e-6);
assert!((end[1] - (-0.3)).abs() < 1e-4);
}
#[test]
fn axis_config_defaults() {
let cfg = AxisConfig::default();
assert!(cfg.show_box);
assert!(cfg.show_labels);
assert!(cfg.show_ticks);
assert!(!cfg.show_grid);
assert_eq!(cfg.labels[0].as_deref(), Some("x"));
assert_eq!(cfg.labels[1].as_deref(), Some("y"));
assert_eq!(cfg.labels[2].as_deref(), Some("z"));
assert_eq!(cfg.tick_count, [5, 5, 5]);
assert!(cfg.axis_colours[0][0] > cfg.axis_colours[0][1]); assert!(cfg.axis_colours[1][1] > cfg.axis_colours[1][0]); assert!(cfg.axis_colours[2][2] > cfg.axis_colours[2][0]); }
#[test]
fn build_axis_polyline_returns_six_items_when_both_enabled() {
let domain = default_domain();
let cfg = AxisConfig::default();
let ticks: Vec<(f64, String)> = vec![(0.0, "0".to_string())];
let ticks_per_axis = [ticks.clone(), ticks.clone(), ticks.clone()];
let items = build_axis_polyline(&domain, &cfg, &ticks_per_axis);
assert_eq!(items.len(), 6, "should produce 3 axis lines + 3 tick sets");
}
#[test]
fn build_axis_labels_returns_ticks_plus_axis_names() {
let domain = default_domain();
let cfg = AxisConfig::default();
let ticks: Vec<(f64, String)> = vec![
(-10.0, "-10".to_string()),
(0.0, "0".to_string()),
(10.0, "10".to_string()),
];
let ticks_per_axis = [ticks.clone(), ticks.clone(), ticks.clone()];
let labels = build_axis_labels(&domain, &cfg, &ticks_per_axis);
assert_eq!(
labels.len(),
10,
"expected 7 tick + 3 name labels (origin deduplicated)"
);
let texts: Vec<&str> = labels.iter().map(|l| l.text.as_str()).collect();
assert!(texts.contains(&"x"), "missing x label");
assert!(texts.contains(&"y"), "missing y label");
assert!(texts.contains(&"z"), "missing z label");
}
}