Skip to main content

elevator_core/components/
line.rs

1//! Line (physical path) component — shaft, tether, track, etc.
2
3use serde::{Deserialize, Serialize};
4
5use crate::ids::GroupId;
6
7/// Physical orientation of a line.
8///
9/// This is metadata for external systems (rendering, spatial queries).
10/// The simulation always operates along a 1D axis regardless of orientation.
11#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
12#[non_exhaustive]
13pub enum Orientation {
14    /// Standard vertical elevator shaft.
15    #[default]
16    Vertical,
17    /// Angled incline (e.g., funicular).
18    Angled {
19        /// Angle from horizontal in degrees (0 = horizontal, 90 = vertical).
20        degrees: f64,
21    },
22    /// Horizontal people-mover or transit line.
23    Horizontal,
24}
25
26impl std::fmt::Display for Orientation {
27    /// Bounded precision keeps TUI/HUD output stable when `degrees` is the
28    /// result of a radians→degrees conversion that doesn't round-trip cleanly.
29    ///
30    /// ```
31    /// # use elevator_core::components::Orientation;
32    /// assert_eq!(format!("{}", Orientation::Vertical), "vertical");
33    /// assert_eq!(format!("{}", Orientation::Horizontal), "horizontal");
34    /// assert_eq!(format!("{}", Orientation::Angled { degrees: 30.0 }), "30.0°");
35    /// assert_eq!(
36    ///     format!("{}", Orientation::Angled { degrees: 22.123_456_789 }),
37    ///     "22.1°",
38    /// );
39    /// ```
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            Self::Vertical => f.write_str("vertical"),
43            Self::Horizontal => f.write_str("horizontal"),
44            Self::Angled { degrees } => write!(f, "{degrees:.1}°"),
45        }
46    }
47}
48
49/// 2D position on a floor plan (for spatial queries and rendering).
50///
51/// On a [`LineKind::Linear`] line this anchors one end of the axis. On a
52/// [`LineKind::Loop`] line this anchors the geometric *center* of the
53/// loop; hosts derive any rendering radius from
54/// [`Line::circumference`] (e.g. `r = C / (2π)` for a circular layout).
55#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
56pub struct SpatialPosition {
57    /// X coordinate on the floor plan.
58    pub x: f64,
59    /// Y coordinate on the floor plan.
60    pub y: f64,
61}
62
63/// Topology of a line — open-ended linear axis or closed loop.
64///
65/// `Linear` is the default for elevator shafts, tethers, and other paths
66/// bounded by `[min, max]`. `Loop` (gated behind the `loop_lines`
67/// feature) models a closed-loop transit line where positions wrap
68/// modulo `circumference`. Helpers in [`super::cyclic`] operate on Loop
69/// positions; consumer code dispatches on this enum to pick linear vs
70/// cyclic semantics.
71///
72/// `#[non_exhaustive]` — future topologies (figure-eight, branching, etc.)
73/// can be added without a major version bump.
74#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
75#[non_exhaustive]
76pub enum LineKind {
77    /// Open-ended line with hard `[min, max]` position bounds. Cars
78    /// reverse at endpoints; this matches every existing dispatch
79    /// strategy (LOOK, sweep, scan, destination, etc.).
80    Linear {
81        /// Lowest reachable position.
82        min: f64,
83        /// Highest reachable position.
84        max: f64,
85    },
86    /// Closed loop with cyclic position semantics. Positions wrap into
87    /// `[0, circumference)`; cars travel one direction only and
88    /// maintain a strict no-overtake ordering with at least
89    /// `min_headway` between successive cars.
90    #[cfg(feature = "loop_lines")]
91    Loop {
92        /// Total path length around the loop. Must be `> 0` —
93        /// construction (`Simulation::add_line` and the explicit-topology
94        /// builder) rejects non-positive or non-finite values.
95        circumference: f64,
96        /// Minimum permitted forward distance between successive cars.
97        /// PR 3 will add construction-time validation
98        /// (`max_cars * min_headway <= circumference`); until then, the
99        /// dispatch and movement code added in subsequent PRs is
100        /// responsible for behaving sanely on degenerate values.
101        /// Callers should set this strictly positive.
102        min_headway: f64,
103    },
104}
105
106impl LineKind {
107    /// Whether this line is a closed loop.
108    #[must_use]
109    pub const fn is_loop(&self) -> bool {
110        match self {
111            Self::Linear { .. } => false,
112            #[cfg(feature = "loop_lines")]
113            Self::Loop { .. } => true,
114        }
115    }
116
117    /// Validate that this kind's intrinsic bounds are well-formed.
118    ///
119    /// Returns `Err((field, reason))` on a violation; both construction
120    /// entry points ([`Simulation::add_line`](crate::sim::Simulation::add_line)
121    /// and the explicit-topology builder) call this and lift the error
122    /// into [`SimError::InvalidConfig`](crate::error::SimError::InvalidConfig).
123    ///
124    /// The intent is the *trivial* per-kind sanity checks — bounds finite
125    /// and ordered, circumference positive. Cross-line invariants
126    /// (`max_cars` × headway, group homogeneity, initial spacing) are PR 3.
127    ///
128    /// # Errors
129    ///
130    /// `Linear` rejects non-finite or `min > max` bounds. `Loop` rejects
131    /// non-finite or non-positive `circumference`.
132    pub fn validate(&self) -> Result<(), (&'static str, String)> {
133        match self {
134            Self::Linear { min, max } => {
135                if !min.is_finite() || !max.is_finite() {
136                    return Err((
137                        "line.range",
138                        format!("min/max must be finite (got min={min}, max={max})"),
139                    ));
140                }
141                if min > max {
142                    return Err(("line.range", format!("min ({min}) must be <= max ({max})")));
143                }
144            }
145            #[cfg(feature = "loop_lines")]
146            Self::Loop {
147                circumference,
148                min_headway,
149            } => {
150                if !circumference.is_finite() || *circumference <= 0.0 {
151                    return Err((
152                        "line.kind",
153                        format!("loop circumference must be finite and > 0 (got {circumference})"),
154                    ));
155                }
156                // Negative `min_headway` would make `headway_clamp_target`
157                // compute `safe_advance = gap - min_headway = gap + |min_headway|`,
158                // i.e. the trailer would be allowed to advance *past* the
159                // leader. Reject up front so the cross-field guard
160                // (`max_cars * min_headway <= circumference`) can't be
161                // bypassed by sign-flipping `required` to negative.
162                if !min_headway.is_finite() || *min_headway <= 0.0 {
163                    return Err((
164                        "line.kind",
165                        format!("loop min_headway must be finite and > 0 (got {min_headway})"),
166                    ));
167                }
168            }
169        }
170        Ok(())
171    }
172}
173
174/// Component for a line entity — the physical path an elevator car travels.
175///
176/// In a building this is a hoistway/shaft. For a space elevator it is a
177/// tether or cable. For a metro or people-mover (with the `loop_lines`
178/// feature) it is a closed loop. The term "line" is domain-neutral.
179///
180/// A line belongs to exactly one [`GroupId`] at a time but can be
181/// reassigned at runtime (swing-car pattern). Multiple cars may share
182/// a line (multi-car shafts); collision avoidance is left to game hooks
183/// for `Linear` lines and enforced by headway clamping for `Loop` lines.
184///
185/// Intrinsic properties only — relationship data (which elevators, which
186/// stops) lives in [`LineInfo`](crate::dispatch::LineInfo) on the
187/// [`ElevatorGroup`](crate::dispatch::ElevatorGroup).
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189#[serde(from = "LineWire", into = "LineWire")]
190pub struct Line {
191    /// Human-readable name.
192    pub(crate) name: String,
193    /// Dispatch group this line currently belongs to.
194    pub(crate) group: GroupId,
195    /// Physical orientation (metadata for rendering).
196    pub(crate) orientation: Orientation,
197    /// Optional floor-plan position (for spatial queries / rendering).
198    pub(crate) position: Option<SpatialPosition>,
199    /// Topology kind — open-ended linear axis or closed loop.
200    pub(crate) kind: LineKind,
201    /// Maximum number of cars allowed on this line (None = unlimited).
202    pub(crate) max_cars: Option<usize>,
203}
204
205/// On-the-wire representation of [`Line`]. Bridges the legacy flat
206/// `min_position` / `max_position` fields with the new `kind` field
207/// during the transitional release: snapshots written by builds that
208/// haven't migrated yet still deserialize correctly, and snapshots
209/// from this build can still be inspected by older tooling.
210///
211/// On serialize we emit `kind` *and* derived flat fields. On deserialize,
212/// `kind` is preferred; flat fields are the fallback.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214struct LineWire {
215    /// Human-readable name (always present in both legacy and current shapes).
216    name: String,
217    /// Dispatch group ownership.
218    group: GroupId,
219    /// Physical orientation (defaults to Vertical if absent).
220    #[serde(default)]
221    orientation: Orientation,
222    /// 2D anchor on the floor plan (defaults to None).
223    #[serde(default)]
224    position: Option<SpatialPosition>,
225    /// New topology field — preferred when present.
226    #[serde(default)]
227    kind: Option<LineKind>,
228    /// Legacy flat field — fallback when `kind` is absent.
229    #[serde(default)]
230    min_position: Option<f64>,
231    /// Legacy flat field — fallback when `kind` is absent.
232    #[serde(default)]
233    max_position: Option<f64>,
234    /// Maximum cars on this line.
235    #[serde(default)]
236    max_cars: Option<usize>,
237}
238
239impl From<LineWire> for Line {
240    fn from(w: LineWire) -> Self {
241        let kind = w.kind.unwrap_or_else(|| LineKind::Linear {
242            min: w.min_position.unwrap_or(0.0),
243            max: w.max_position.unwrap_or(0.0),
244        });
245        Self {
246            name: w.name,
247            group: w.group,
248            orientation: w.orientation,
249            position: w.position,
250            kind,
251            max_cars: w.max_cars,
252        }
253    }
254}
255
256impl From<Line> for LineWire {
257    fn from(l: Line) -> Self {
258        let (min_position, max_position) = match &l.kind {
259            LineKind::Linear { min, max } => (Some(*min), Some(*max)),
260            #[cfg(feature = "loop_lines")]
261            LineKind::Loop { circumference, .. } => (Some(0.0), Some(*circumference)),
262        };
263        Self {
264            name: l.name,
265            group: l.group,
266            orientation: l.orientation,
267            position: l.position,
268            kind: Some(l.kind),
269            min_position,
270            max_position,
271            max_cars: l.max_cars,
272        }
273    }
274}
275
276impl Line {
277    /// Human-readable name.
278    #[must_use]
279    pub fn name(&self) -> &str {
280        &self.name
281    }
282
283    /// Dispatch group this line currently belongs to.
284    #[must_use]
285    pub const fn group(&self) -> GroupId {
286        self.group
287    }
288
289    /// Physical orientation.
290    #[must_use]
291    pub const fn orientation(&self) -> Orientation {
292        self.orientation
293    }
294
295    /// Optional floor-plan position. For [`LineKind::Loop`] this is the
296    /// geometric *center* of the loop; hosts derive a rendering radius
297    /// from [`Self::circumference`].
298    #[must_use]
299    pub const fn position(&self) -> Option<&SpatialPosition> {
300        self.position.as_ref()
301    }
302
303    /// Topology kind — linear axis or closed loop.
304    #[must_use]
305    pub const fn kind(&self) -> &LineKind {
306        &self.kind
307    }
308
309    /// Whether this is a closed-loop line.
310    #[must_use]
311    pub const fn is_loop(&self) -> bool {
312        self.kind.is_loop()
313    }
314
315    /// Lowest reachable position on a [`LineKind::Linear`] line. Returns
316    /// `None` for [`LineKind::Loop`] — loops have no endpoints.
317    ///
318    /// Replaces the former `min_position()` accessor. Callers that
319    /// blindly dereferenced the old `f64` should now decide whether
320    /// they want Linear-only behavior (`linear_min().expect("linear")`)
321    /// or to handle Loop explicitly.
322    #[must_use]
323    pub const fn linear_min(&self) -> Option<f64> {
324        match self.kind {
325            LineKind::Linear { min, .. } => Some(min),
326            #[cfg(feature = "loop_lines")]
327            LineKind::Loop { .. } => None,
328        }
329    }
330
331    /// Highest reachable position on a [`LineKind::Linear`] line. Returns
332    /// `None` for [`LineKind::Loop`].
333    #[must_use]
334    pub const fn linear_max(&self) -> Option<f64> {
335        match self.kind {
336            LineKind::Linear { max, .. } => Some(max),
337            #[cfg(feature = "loop_lines")]
338            LineKind::Loop { .. } => None,
339        }
340    }
341
342    /// Total path length of a [`LineKind::Loop`] line. Returns `None`
343    /// for [`LineKind::Linear`].
344    #[must_use]
345    pub const fn circumference(&self) -> Option<f64> {
346        match self.kind {
347            LineKind::Linear { .. } => None,
348            #[cfg(feature = "loop_lines")]
349            LineKind::Loop { circumference, .. } => Some(circumference),
350        }
351    }
352
353    /// Minimum forward distance between successive cars on a
354    /// [`LineKind::Loop`] line. Returns `None` for [`LineKind::Linear`].
355    #[must_use]
356    pub const fn min_headway(&self) -> Option<f64> {
357        match self.kind {
358            LineKind::Linear { .. } => None,
359            #[cfg(feature = "loop_lines")]
360            LineKind::Loop { min_headway, .. } => Some(min_headway),
361        }
362    }
363
364    /// Maximum number of cars allowed on this line.
365    #[must_use]
366    pub const fn max_cars(&self) -> Option<usize> {
367        self.max_cars
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn linear_accessors_return_some() {
377        let line = Line::from(LineWire {
378            name: "L1".into(),
379            group: GroupId(0),
380            orientation: Orientation::Vertical,
381            position: None,
382            kind: Some(LineKind::Linear {
383                min: 0.0,
384                max: 100.0,
385            }),
386            min_position: None,
387            max_position: None,
388            max_cars: None,
389        });
390        assert_eq!(line.linear_min(), Some(0.0));
391        assert_eq!(line.linear_max(), Some(100.0));
392        assert_eq!(line.circumference(), None);
393        assert_eq!(line.min_headway(), None);
394        assert!(!line.is_loop());
395    }
396
397    #[test]
398    fn legacy_flat_fields_construct_linear_kind() {
399        let line = Line::from(LineWire {
400            name: "L1".into(),
401            group: GroupId(0),
402            orientation: Orientation::Vertical,
403            position: None,
404            kind: None,
405            min_position: Some(0.0),
406            max_position: Some(50.0),
407            max_cars: None,
408        });
409        assert_eq!(
410            line.kind(),
411            &LineKind::Linear {
412                min: 0.0,
413                max: 50.0
414            }
415        );
416    }
417
418    #[test]
419    #[allow(clippy::unwrap_used, reason = "test helper")]
420    fn round_trip_writes_both_kind_and_flat_fields() {
421        let line = Line {
422            name: "L1".into(),
423            group: GroupId(0),
424            orientation: Orientation::Vertical,
425            position: None,
426            kind: LineKind::Linear {
427                min: 0.0,
428                max: 75.0,
429            },
430            max_cars: None,
431        };
432        let serialized = serde_json::to_value(&line).unwrap();
433        // Both shapes must be present so an older deserializer can still read it.
434        assert!(serialized.get("kind").is_some());
435        assert_eq!(
436            serialized
437                .get("min_position")
438                .and_then(serde_json::Value::as_f64),
439            Some(0.0)
440        );
441        assert_eq!(
442            serialized
443                .get("max_position")
444                .and_then(serde_json::Value::as_f64),
445            Some(75.0)
446        );
447
448        let deserialized: Line = serde_json::from_value(serialized).unwrap();
449        assert_eq!(deserialized.kind(), line.kind());
450    }
451
452    #[test]
453    fn validate_rejects_non_finite_linear() {
454        assert!(
455            LineKind::Linear {
456                min: f64::NAN,
457                max: 10.0
458            }
459            .validate()
460            .is_err()
461        );
462        assert!(
463            LineKind::Linear {
464                min: 5.0,
465                max: f64::INFINITY
466            }
467            .validate()
468            .is_err()
469        );
470    }
471
472    #[test]
473    fn validate_rejects_inverted_linear_bounds() {
474        assert!(
475            LineKind::Linear {
476                min: 10.0,
477                max: 5.0
478            }
479            .validate()
480            .is_err()
481        );
482    }
483
484    #[test]
485    fn validate_accepts_well_formed_linear() {
486        assert!(
487            LineKind::Linear {
488                min: 0.0,
489                max: 100.0
490            }
491            .validate()
492            .is_ok()
493        );
494    }
495
496    #[cfg(feature = "loop_lines")]
497    #[test]
498    fn validate_rejects_non_positive_circumference() {
499        assert!(
500            LineKind::Loop {
501                circumference: 0.0,
502                min_headway: 5.0
503            }
504            .validate()
505            .is_err()
506        );
507        assert!(
508            LineKind::Loop {
509                circumference: -1.0,
510                min_headway: 5.0
511            }
512            .validate()
513            .is_err()
514        );
515        assert!(
516            LineKind::Loop {
517                circumference: f64::NAN,
518                min_headway: 5.0
519            }
520            .validate()
521            .is_err()
522        );
523    }
524
525    #[cfg(feature = "loop_lines")]
526    #[test]
527    fn validate_accepts_positive_circumference() {
528        assert!(
529            LineKind::Loop {
530                circumference: 100.0,
531                min_headway: 5.0
532            }
533            .validate()
534            .is_ok()
535        );
536    }
537
538    #[cfg(feature = "loop_lines")]
539    #[test]
540    fn validate_rejects_non_positive_min_headway() {
541        // Negative `min_headway` would let `headway_clamp_target` compute
542        // a `safe_advance` larger than the actual gap, allowing overtaking.
543        for bad in [0.0_f64, -1.0, f64::NAN] {
544            let result = LineKind::Loop {
545                circumference: 100.0,
546                min_headway: bad,
547            }
548            .validate();
549            assert!(
550                result.is_err(),
551                "min_headway={bad} should have been rejected, got {result:?}",
552            );
553        }
554    }
555
556    #[cfg(feature = "loop_lines")]
557    #[test]
558    fn loop_accessors_return_some() {
559        let line = Line::from(LineWire {
560            name: "L1".into(),
561            group: GroupId(0),
562            orientation: Orientation::Horizontal,
563            position: None,
564            kind: Some(LineKind::Loop {
565                circumference: 200.0,
566                min_headway: 10.0,
567            }),
568            min_position: None,
569            max_position: None,
570            max_cars: None,
571        });
572        assert_eq!(line.linear_min(), None);
573        assert_eq!(line.linear_max(), None);
574        assert_eq!(line.circumference(), Some(200.0));
575        assert_eq!(line.min_headway(), Some(10.0));
576        assert!(line.is_loop());
577    }
578}