1use glam::{Mat4, Vec2, Vec3};
15use serde::{Deserialize, Serialize};
16use viewport_lib::{LabelItem, PolylineItem};
17
18use crate::domain::Domain;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Axis3 {
27 X,
28 Y,
29 Z,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct AxisConfig {
39 pub show_box: bool,
41
42 pub show_labels: bool,
44
45 pub show_ticks: bool,
47
48 pub show_grid: bool,
50
51 pub labels: [Option<String>; 3],
54
55 pub tick_count: [u32; 3],
57
58 pub axis_colours: [[f32; 4]; 3],
61
62 pub tick_colour: [f32; 4],
64}
65
66impl Default for AxisConfig {
67 fn default() -> Self {
68 let dim = [0.7, 0.7, 0.7, 1.0];
69 Self {
70 show_box: true,
71 show_labels: true,
72 show_ticks: true,
73 show_grid: false,
74 labels: [
75 Some("x".to_string()),
76 Some("y".to_string()),
77 Some("z".to_string()),
78 ],
79 tick_count: [5, 5, 5],
80 axis_colours: [
81 [0.9, 0.2, 0.2, 1.0], [0.2, 0.9, 0.2, 1.0], [0.2, 0.2, 0.9, 1.0], ],
85 tick_colour: dim,
86 }
87 }
88}
89
90pub fn build_axis_lines(domain: &Domain) -> [([f32; 3], [f32; 3]); 3] {
99 let x0 = *domain.x.start() as f32;
100 let x1 = *domain.x.end() as f32;
101 let y0 = *domain.y.start() as f32;
102 let y1 = *domain.y.end() as f32;
103 let z0 = *domain.z.start() as f32;
104 let z1 = *domain.z.end() as f32;
105
106 [
107 ([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]), ]
111}
112
113const MAX_STUB_LENGTH: f32 = 0.3;
119const MIN_STUB_LENGTH: f32 = 1e-4;
121const MAX_LABEL_OFFSET: f32 = 0.6;
123const MIN_LABEL_OFFSET: f32 = 5e-4;
125const TARGET_TICK_NDC: f32 = 0.025;
127const TARGET_LABEL_OFFSET_NDC: f32 = 0.035;
129
130fn axis_span(domain: &Domain, axis: Axis3) -> f32 {
131 match axis {
132 Axis3::X => (*domain.x.end() - *domain.x.start()) as f32,
133 Axis3::Y => (*domain.y.end() - *domain.y.start()) as f32,
134 Axis3::Z => (*domain.z.end() - *domain.z.start()) as f32,
135 }
136 .abs()
137}
138
139fn tick_step_hint(domain: &Domain, axis: Axis3, tick_positions: &[(f64, String)]) -> f32 {
140 let mut deltas: Vec<f32> = tick_positions
141 .windows(2)
142 .map(|pair| (pair[1].0 - pair[0].0).abs() as f32)
143 .filter(|d| *d > 1e-6)
144 .collect();
145 if deltas.is_empty() {
146 let span = axis_span(domain, axis);
147 return (span / 5.0).max(MIN_STUB_LENGTH);
148 }
149 deltas.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
150 deltas[deltas.len() / 2]
151}
152
153fn stub_length_for_axis(domain: &Domain, axis: Axis3, tick_positions: &[(f64, String)]) -> f32 {
154 (tick_step_hint(domain, axis, tick_positions) * 0.18).clamp(MIN_STUB_LENGTH, MAX_STUB_LENGTH)
155}
156
157fn label_offset_for_axis(domain: &Domain, axis: Axis3, tick_positions: &[(f64, String)]) -> f32 {
158 (stub_length_for_axis(domain, axis, tick_positions) * 1.4)
159 .clamp(MIN_LABEL_OFFSET, MAX_LABEL_OFFSET)
160}
161
162fn stub_direction(axis: Axis3) -> Vec3 {
163 match axis {
164 Axis3::X => Vec3::NEG_Y,
165 Axis3::Y => Vec3::NEG_X,
166 Axis3::Z => Vec3::NEG_Y,
167 }
168}
169
170fn axis_point(axis: Axis3, pos: f64) -> Vec3 {
171 let p = pos as f32;
172 match axis {
173 Axis3::X => Vec3::new(p, 0.0, 0.0),
174 Axis3::Y => Vec3::new(0.0, p, 0.0),
175 Axis3::Z => Vec3::new(0.0, 0.0, p),
176 }
177}
178
179fn project_to_ndc_xy(vp: &Mat4, world: Vec3) -> Option<Vec2> {
180 let clip = *vp * world.extend(1.0);
181 if clip.w.abs() < 1e-6 || clip.z < -clip.w || clip.z > clip.w {
182 return None;
183 }
184 let inv_w = 1.0 / clip.w;
185 Some(Vec2::new(clip.x * inv_w, clip.y * inv_w))
186}
187
188fn projected_world_extent(
189 vp: &Mat4,
190 origin: Vec3,
191 direction: Vec3,
192 target_ndc: f32,
193) -> Option<f32> {
194 let base = project_to_ndc_xy(vp, origin)?;
195 let mut lo = MIN_STUB_LENGTH;
196 let mut hi = MAX_STUB_LENGTH;
197 let end_hi = project_to_ndc_xy(vp, origin + direction * hi)?;
198 if (end_hi - base).length() <= target_ndc {
199 return Some(hi);
200 }
201 for _ in 0..20 {
202 let mid = 0.5 * (lo + hi);
203 let end = project_to_ndc_xy(vp, origin + direction * mid)?;
204 if (end - base).length() < target_ndc {
205 lo = mid;
206 } else {
207 hi = mid;
208 }
209 }
210 Some(hi)
211}
212
213fn projected_stub_length(
214 domain: &Domain,
215 vp: Option<&Mat4>,
216 axis: Axis3,
217 tick_positions: &[(f64, String)],
218 pos: f64,
219) -> f32 {
220 let fallback = stub_length_for_axis(domain, axis, tick_positions);
221 let Some(vp) = vp else { return fallback };
222 projected_world_extent(
223 vp,
224 axis_point(axis, pos),
225 stub_direction(axis),
226 TARGET_TICK_NDC,
227 )
228 .unwrap_or(fallback)
229 .clamp(MIN_STUB_LENGTH, MAX_STUB_LENGTH)
230}
231
232fn projected_label_offset(
233 domain: &Domain,
234 vp: Option<&Mat4>,
235 axis: Axis3,
236 tick_positions: &[(f64, String)],
237 pos: f64,
238) -> f32 {
239 let fallback = label_offset_for_axis(domain, axis, tick_positions);
240 let Some(vp) = vp else { return fallback };
241 projected_world_extent(
242 vp,
243 axis_point(axis, pos),
244 stub_direction(axis),
245 TARGET_LABEL_OFFSET_NDC,
246 )
247 .unwrap_or(fallback)
248 .clamp(MIN_LABEL_OFFSET, MAX_LABEL_OFFSET)
249}
250
251pub fn build_tick_stubs(
259 domain: &Domain,
260 axis: Axis3,
261 tick_positions: &[(f64, String)],
262) -> Vec<([f32; 3], [f32; 3])> {
263 build_tick_stubs_projected(domain, None, axis, tick_positions)
264}
265
266pub fn build_tick_stubs_projected(
267 domain: &Domain,
268 vp: Option<&Mat4>,
269 axis: Axis3,
270 tick_positions: &[(f64, String)],
271) -> Vec<([f32; 3], [f32; 3])> {
272 tick_positions
273 .iter()
274 .map(|(pos, _)| {
275 let p = *pos as f32;
276 let stub_length = projected_stub_length(domain, vp, axis, tick_positions, *pos);
277 match axis {
278 Axis3::X => ([p, 0.0, 0.0], [p, -stub_length, 0.0]),
279 Axis3::Y => ([0.0, p, 0.0], [-stub_length, p, 0.0]),
280 Axis3::Z => ([0.0, 0.0, p], [0.0, -stub_length, p]),
281 }
282 })
283 .collect()
284}
285
286pub fn build_axis_polyline(
299 domain: &Domain,
300 config: &AxisConfig,
301 ticks_per_axis: &[Vec<(f64, String)>; 3],
302) -> Vec<PolylineItem> {
303 build_axis_polyline_projected(domain, config, ticks_per_axis, None)
304}
305
306pub fn build_axis_polyline_projected(
307 domain: &Domain,
308 config: &AxisConfig,
309 ticks_per_axis: &[Vec<(f64, String)>; 3],
310 vp: Option<&Mat4>,
311) -> Vec<PolylineItem> {
312 let mut items = Vec::new();
313 let axes = [Axis3::X, Axis3::Y, Axis3::Z];
314 let lines = build_axis_lines(domain);
315
316 for (i, axis) in axes.iter().enumerate() {
317 let colour = config.axis_colours[i];
318
319 if config.show_box {
321 let (a, b) = lines[i];
322 let mut item = PolylineItem::default();
323 item.positions = vec![a, b];
324 item.scalars = Vec::new();
325 item.strip_lengths = vec![2];
326 item.scalar_range = None;
327 item.colourmap_id = None;
328 item.default_colour = colour;
329 item.line_width = 1.5;
330 items.push(item);
331 }
332
333 if config.show_ticks {
335 let stubs = build_tick_stubs_projected(domain, vp, *axis, &ticks_per_axis[i]);
336 if !stubs.is_empty() {
337 let mut positions: Vec<[f32; 3]> = Vec::with_capacity(stubs.len() * 2);
338 let mut strip_lengths: Vec<u32> = Vec::with_capacity(stubs.len());
339 for (a, b) in stubs {
340 positions.push(a);
341 positions.push(b);
342 strip_lengths.push(2);
343 }
344 let mut item = PolylineItem::default();
345 item.positions = positions;
346 item.scalars = Vec::new();
347 item.strip_lengths = strip_lengths;
348 item.scalar_range = None;
349 item.colourmap_id = None;
350 item.default_colour = colour;
351 item.line_width = 1.0;
352 items.push(item);
353 }
354 }
355 }
356
357 items
358}
359
360pub(crate) fn tick_label_anchor(
365 domain: &Domain,
366 vp: Option<&Mat4>,
367 axis: Axis3,
368 tick_positions: &[(f64, String)],
369 pos: f64,
370) -> Vec3 {
371 let offset = projected_label_offset(domain, vp, axis, tick_positions, pos);
372 let p = pos as f32;
373 match axis {
374 Axis3::X => Vec3::new(p, -offset, 0.0),
375 Axis3::Y => Vec3::new(-offset, p, 0.0),
376 Axis3::Z => Vec3::new(-offset, 0.0, p),
377 }
378}
379
380pub fn build_axis_labels(
385 domain: &Domain,
386 config: &AxisConfig,
387 ticks_per_axis: &[Vec<(f64, String)>; 3],
388) -> Vec<LabelItem> {
389 build_axis_labels_projected(domain, config, ticks_per_axis, None)
390}
391
392pub fn build_axis_labels_projected(
393 domain: &Domain,
394 config: &AxisConfig,
395 ticks_per_axis: &[Vec<(f64, String)>; 3],
396 vp: Option<&Mat4>,
397) -> Vec<LabelItem> {
398 let x1 = *domain.x.end() as f32;
399 let y1 = *domain.y.end() as f32;
400 let z1 = *domain.z.end() as f32;
401
402 let tc = config.tick_colour;
403 let mut labels: Vec<LabelItem> = Vec::new();
404
405 if config.show_labels {
409 let mut origin_labelled = false;
410
411 for (pos, text) in &ticks_per_axis[0] {
412 if *pos == 0.0 {
413 if origin_labelled {
414 continue;
415 }
416 origin_labelled = true;
417 }
418 let mut lbl = LabelItem::default();
419 lbl.world_anchor =
420 Some(tick_label_anchor(domain, vp, Axis3::X, &ticks_per_axis[0], *pos).to_array());
421 lbl.text = text.clone();
422 lbl.colour = tc;
423 lbl.font_size = 11.0;
424 labels.push(lbl);
425 }
426 for (pos, text) in &ticks_per_axis[1] {
427 if *pos == 0.0 {
428 if origin_labelled {
429 continue;
430 }
431 origin_labelled = true;
432 }
433 let mut lbl = LabelItem::default();
434 lbl.world_anchor =
435 Some(tick_label_anchor(domain, vp, Axis3::Y, &ticks_per_axis[1], *pos).to_array());
436 lbl.text = text.clone();
437 lbl.colour = tc;
438 lbl.font_size = 11.0;
439 labels.push(lbl);
440 }
441 for (pos, text) in &ticks_per_axis[2] {
442 if *pos == 0.0 {
443 if origin_labelled {
444 continue;
445 }
446 origin_labelled = true;
447 }
448 let mut lbl = LabelItem::default();
449 lbl.world_anchor =
450 Some(tick_label_anchor(domain, vp, Axis3::Z, &ticks_per_axis[2], *pos).to_array());
451 lbl.text = text.clone();
452 lbl.colour = tc;
453 lbl.font_size = 11.0;
454 labels.push(lbl);
455 }
456 }
457
458 let name_positions = [
460 (
461 config.labels[0].as_deref(),
462 Vec3::new(
463 x1 + projected_label_offset(
464 domain,
465 vp,
466 Axis3::X,
467 &ticks_per_axis[0],
468 *domain.x.end(),
469 ),
470 0.0,
471 0.0,
472 ),
473 ),
474 (
475 config.labels[1].as_deref(),
476 Vec3::new(
477 0.0,
478 y1 + projected_label_offset(
479 domain,
480 vp,
481 Axis3::Y,
482 &ticks_per_axis[1],
483 *domain.y.end(),
484 ),
485 0.0,
486 ),
487 ),
488 (
489 config.labels[2].as_deref(),
490 Vec3::new(
491 0.0,
492 0.0,
493 z1 + projected_label_offset(
494 domain,
495 vp,
496 Axis3::Z,
497 &ticks_per_axis[2],
498 *domain.z.end(),
499 ),
500 ),
501 ),
502 ];
503
504 if config.show_labels {
505 for (name_opt, world_pos) in &name_positions {
506 let Some(name) = name_opt else { continue };
507 let mut lbl = LabelItem::default();
508 lbl.world_anchor = Some(world_pos.to_array());
509 lbl.text = name.to_string();
510 lbl.colour = tc;
511 lbl.font_size = 13.0;
512 labels.push(lbl);
513 }
514 }
515
516 labels
517}
518
519#[cfg(test)]
524mod tests {
525 use super::*;
526 use crate::domain::Domain;
527
528 fn default_domain() -> Domain {
529 Domain::default() }
531
532 #[test]
533 fn axis_lines_has_three() {
534 let lines = build_axis_lines(&default_domain());
535 assert_eq!(lines.len(), 3, "must produce exactly 3 axis lines");
536 assert_eq!(lines[0].0[1], 0.0);
538 assert_eq!(lines[0].0[2], 0.0);
539 }
540
541 #[test]
542 fn tick_stubs_match_count() {
543 let domain = default_domain();
544 let ticks: Vec<(f64, String)> = vec![
545 (-10.0, "-10".to_string()),
546 (-5.0, "-5".to_string()),
547 (0.0, "0".to_string()),
548 (5.0, "5".to_string()),
549 (10.0, "10".to_string()),
550 ];
551 let stubs = build_tick_stubs(&domain, Axis3::X, &ticks);
552 assert_eq!(stubs.len(), ticks.len());
553 }
554
555 #[test]
556 fn tick_stubs_x_direction() {
557 let domain = default_domain();
558 let ticks = vec![(5.0_f64, "5".to_string())];
559 let stubs = build_tick_stubs(&domain, Axis3::X, &ticks);
560 let (start, end) = stubs[0];
562 assert!((start[0] - 5.0).abs() < 1e-6);
563 assert!((start[1] - 0.0).abs() < 1e-6);
564 assert!((end[1] - (-0.3)).abs() < 1e-4);
565 }
566
567 #[test]
568 fn axis_config_defaults() {
569 let cfg = AxisConfig::default();
570 assert!(cfg.show_box);
571 assert!(cfg.show_labels);
572 assert!(cfg.show_ticks);
573 assert!(!cfg.show_grid);
574 assert_eq!(cfg.labels[0].as_deref(), Some("x"));
575 assert_eq!(cfg.labels[1].as_deref(), Some("y"));
576 assert_eq!(cfg.labels[2].as_deref(), Some("z"));
577 assert_eq!(cfg.tick_count, [5, 5, 5]);
578 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]); }
583
584 #[test]
585 fn build_axis_polyline_returns_six_items_when_both_enabled() {
586 let domain = default_domain();
587 let cfg = AxisConfig::default();
588 let ticks: Vec<(f64, String)> = vec![(0.0, "0".to_string())];
589 let ticks_per_axis = [ticks.clone(), ticks.clone(), ticks.clone()];
590 let items = build_axis_polyline(&domain, &cfg, &ticks_per_axis);
591 assert_eq!(items.len(), 6, "should produce 3 axis lines + 3 tick sets");
592 }
593
594 #[test]
595 fn build_axis_labels_returns_ticks_plus_axis_names() {
596 let domain = default_domain();
597 let cfg = AxisConfig::default();
598 let ticks: Vec<(f64, String)> = vec![
599 (-10.0, "-10".to_string()),
600 (0.0, "0".to_string()),
601 (10.0, "10".to_string()),
602 ];
603 let ticks_per_axis = [ticks.clone(), ticks.clone(), ticks.clone()];
604 let labels = build_axis_labels(&domain, &cfg, &ticks_per_axis);
605 assert_eq!(
607 labels.len(),
608 10,
609 "expected 7 tick + 3 name labels (origin deduplicated)"
610 );
611 let texts: Vec<&str> = labels.iter().map(|l| l.text.as_str()).collect();
613 assert!(texts.contains(&"x"), "missing x label");
614 assert!(texts.contains(&"y"), "missing y label");
615 assert!(texts.contains(&"z"), "missing z label");
616 }
617}