use serde::{Deserialize, Serialize};
use crate::ids::GroupId;
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Orientation {
#[default]
Vertical,
Angled {
degrees: f64,
},
Horizontal,
}
impl std::fmt::Display for Orientation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Vertical => f.write_str("vertical"),
Self::Horizontal => f.write_str("horizontal"),
Self::Angled { degrees } => write!(f, "{degrees:.1}°"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SpatialPosition {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum LineKind {
Linear {
min: f64,
max: f64,
},
#[cfg(feature = "loop_lines")]
Loop {
circumference: f64,
min_headway: f64,
},
}
impl LineKind {
#[must_use]
pub const fn is_loop(&self) -> bool {
match self {
Self::Linear { .. } => false,
#[cfg(feature = "loop_lines")]
Self::Loop { .. } => true,
}
}
#[must_use]
pub const fn is_linear(&self) -> bool {
match self {
Self::Linear { .. } => true,
#[cfg(feature = "loop_lines")]
Self::Loop { .. } => false,
}
}
pub fn validate(&self) -> Result<(), (&'static str, String)> {
match self {
Self::Linear { min, max } => {
if !min.is_finite() || !max.is_finite() {
return Err((
"line.range",
format!("min/max must be finite (got min={min}, max={max})"),
));
}
if min > max {
return Err(("line.range", format!("min ({min}) must be <= max ({max})")));
}
}
#[cfg(feature = "loop_lines")]
Self::Loop {
circumference,
min_headway,
} => {
if !circumference.is_finite() || *circumference <= 0.0 {
return Err((
"line.kind",
format!("loop circumference must be finite and > 0 (got {circumference})"),
));
}
if !min_headway.is_finite() || *min_headway <= 0.0 {
return Err((
"line.kind",
format!("loop min_headway must be finite and > 0 (got {min_headway})"),
));
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(from = "LineWire", into = "LineWire")]
pub struct Line {
pub(crate) name: String,
pub(crate) group: GroupId,
pub(crate) orientation: Orientation,
pub(crate) position: Option<SpatialPosition>,
pub(crate) kind: LineKind,
pub(crate) max_cars: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LineWire {
name: String,
group: GroupId,
#[serde(default)]
orientation: Orientation,
#[serde(default)]
position: Option<SpatialPosition>,
#[serde(default)]
kind: Option<LineKind>,
#[serde(default)]
min_position: Option<f64>,
#[serde(default)]
max_position: Option<f64>,
#[serde(default)]
max_cars: Option<usize>,
}
impl From<LineWire> for Line {
fn from(w: LineWire) -> Self {
let kind = w.kind.unwrap_or_else(|| LineKind::Linear {
min: w.min_position.unwrap_or(0.0),
max: w.max_position.unwrap_or(0.0),
});
Self {
name: w.name,
group: w.group,
orientation: w.orientation,
position: w.position,
kind,
max_cars: w.max_cars,
}
}
}
impl From<Line> for LineWire {
fn from(l: Line) -> Self {
let (min_position, max_position) = match &l.kind {
LineKind::Linear { min, max } => (Some(*min), Some(*max)),
#[cfg(feature = "loop_lines")]
LineKind::Loop { circumference, .. } => (Some(0.0), Some(*circumference)),
};
Self {
name: l.name,
group: l.group,
orientation: l.orientation,
position: l.position,
kind: Some(l.kind),
min_position,
max_position,
max_cars: l.max_cars,
}
}
}
impl Line {
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn group(&self) -> GroupId {
self.group
}
#[must_use]
pub const fn orientation(&self) -> Orientation {
self.orientation
}
#[must_use]
pub const fn position(&self) -> Option<&SpatialPosition> {
self.position.as_ref()
}
#[must_use]
pub const fn kind(&self) -> &LineKind {
&self.kind
}
#[must_use]
pub const fn is_loop(&self) -> bool {
self.kind.is_loop()
}
#[must_use]
pub const fn linear_min(&self) -> Option<f64> {
match self.kind {
LineKind::Linear { min, .. } => Some(min),
#[cfg(feature = "loop_lines")]
LineKind::Loop { .. } => None,
}
}
#[must_use]
pub const fn linear_max(&self) -> Option<f64> {
match self.kind {
LineKind::Linear { max, .. } => Some(max),
#[cfg(feature = "loop_lines")]
LineKind::Loop { .. } => None,
}
}
#[must_use]
pub const fn circumference(&self) -> Option<f64> {
match self.kind {
LineKind::Linear { .. } => None,
#[cfg(feature = "loop_lines")]
LineKind::Loop { circumference, .. } => Some(circumference),
}
}
#[must_use]
pub const fn min_headway(&self) -> Option<f64> {
match self.kind {
LineKind::Linear { .. } => None,
#[cfg(feature = "loop_lines")]
LineKind::Loop { min_headway, .. } => Some(min_headway),
}
}
#[must_use]
pub const fn max_cars(&self) -> Option<usize> {
self.max_cars
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn linear_accessors_return_some() {
let line = Line::from(LineWire {
name: "L1".into(),
group: GroupId(0),
orientation: Orientation::Vertical,
position: None,
kind: Some(LineKind::Linear {
min: 0.0,
max: 100.0,
}),
min_position: None,
max_position: None,
max_cars: None,
});
assert_eq!(line.linear_min(), Some(0.0));
assert_eq!(line.linear_max(), Some(100.0));
assert_eq!(line.circumference(), None);
assert_eq!(line.min_headway(), None);
assert!(!line.is_loop());
}
#[test]
fn legacy_flat_fields_construct_linear_kind() {
let line = Line::from(LineWire {
name: "L1".into(),
group: GroupId(0),
orientation: Orientation::Vertical,
position: None,
kind: None,
min_position: Some(0.0),
max_position: Some(50.0),
max_cars: None,
});
assert_eq!(
line.kind(),
&LineKind::Linear {
min: 0.0,
max: 50.0
}
);
}
#[test]
#[allow(clippy::unwrap_used, reason = "test helper")]
fn round_trip_writes_both_kind_and_flat_fields() {
let line = Line {
name: "L1".into(),
group: GroupId(0),
orientation: Orientation::Vertical,
position: None,
kind: LineKind::Linear {
min: 0.0,
max: 75.0,
},
max_cars: None,
};
let serialized = serde_json::to_value(&line).unwrap();
assert!(serialized.get("kind").is_some());
assert_eq!(
serialized
.get("min_position")
.and_then(serde_json::Value::as_f64),
Some(0.0)
);
assert_eq!(
serialized
.get("max_position")
.and_then(serde_json::Value::as_f64),
Some(75.0)
);
let deserialized: Line = serde_json::from_value(serialized).unwrap();
assert_eq!(deserialized.kind(), line.kind());
}
#[test]
fn validate_rejects_non_finite_linear() {
assert!(
LineKind::Linear {
min: f64::NAN,
max: 10.0
}
.validate()
.is_err()
);
assert!(
LineKind::Linear {
min: 5.0,
max: f64::INFINITY
}
.validate()
.is_err()
);
}
#[test]
fn validate_rejects_inverted_linear_bounds() {
assert!(
LineKind::Linear {
min: 10.0,
max: 5.0
}
.validate()
.is_err()
);
}
#[test]
fn validate_accepts_well_formed_linear() {
assert!(
LineKind::Linear {
min: 0.0,
max: 100.0
}
.validate()
.is_ok()
);
}
#[cfg(feature = "loop_lines")]
#[test]
fn validate_rejects_non_positive_circumference() {
assert!(
LineKind::Loop {
circumference: 0.0,
min_headway: 5.0
}
.validate()
.is_err()
);
assert!(
LineKind::Loop {
circumference: -1.0,
min_headway: 5.0
}
.validate()
.is_err()
);
assert!(
LineKind::Loop {
circumference: f64::NAN,
min_headway: 5.0
}
.validate()
.is_err()
);
}
#[cfg(feature = "loop_lines")]
#[test]
fn validate_accepts_positive_circumference() {
assert!(
LineKind::Loop {
circumference: 100.0,
min_headway: 5.0
}
.validate()
.is_ok()
);
}
#[cfg(feature = "loop_lines")]
#[test]
fn validate_rejects_non_positive_min_headway() {
for bad in [0.0_f64, -1.0, f64::NAN] {
let result = LineKind::Loop {
circumference: 100.0,
min_headway: bad,
}
.validate();
assert!(
result.is_err(),
"min_headway={bad} should have been rejected, got {result:?}",
);
}
}
#[cfg(feature = "loop_lines")]
#[test]
fn loop_accessors_return_some() {
let line = Line::from(LineWire {
name: "L1".into(),
group: GroupId(0),
orientation: Orientation::Horizontal,
position: None,
kind: Some(LineKind::Loop {
circumference: 200.0,
min_headway: 10.0,
}),
min_position: None,
max_position: None,
max_cars: None,
});
assert_eq!(line.linear_min(), None);
assert_eq!(line.linear_max(), None);
assert_eq!(line.circumference(), Some(200.0));
assert_eq!(line.min_headway(), Some(10.0));
assert!(line.is_loop());
}
}