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        /// Construction enforces `n_cars * min_headway <= circumference`
98        /// at both `add_line` (via `max_cars`) and `add_elevator` (per-attach).
99        /// Callers should set this strictly positive.
100        min_headway: f64,
101    },
102}
103
104impl LineKind {
105    /// Returns `true` for [`LineKind::Loop`]; paired with [`Self::is_linear`].
106    #[must_use]
107    pub const fn is_loop(&self) -> bool {
108        match self {
109            Self::Linear { .. } => false,
110            #[cfg(feature = "loop_lines")]
111            Self::Loop { .. } => true,
112        }
113    }
114
115    /// Whether this line is a (linear) open-ended axis.
116    ///
117    /// Paired with [`Self::is_loop`] so consumers can dispatch positively
118    /// on the expected variant rather than negatively on "not Loop", which
119    /// would silently absorb any future topology added to the enum.
120    #[must_use]
121    pub const fn is_linear(&self) -> bool {
122        match self {
123            Self::Linear { .. } => true,
124            #[cfg(feature = "loop_lines")]
125            Self::Loop { .. } => false,
126        }
127    }
128
129    /// Validate that this kind's intrinsic bounds are well-formed.
130    ///
131    /// Returns `Err((field, reason))` on a violation; both construction
132    /// entry points ([`Simulation::add_line`](crate::sim::Simulation::add_line)
133    /// and the explicit-topology builder) call this and lift the error
134    /// into [`SimError::InvalidConfig`](crate::error::SimError::InvalidConfig).
135    ///
136    /// The intent is the *trivial* per-kind sanity checks — bounds finite
137    /// and ordered, circumference positive. Cross-line invariants
138    /// (`max_cars` × headway, group homogeneity) are checked by the
139    /// `Simulation` construction and topology methods, not here.
140    ///
141    /// # Errors
142    ///
143    /// `Linear` rejects non-finite or `min > max` bounds. `Loop` rejects
144    /// non-finite or non-positive `circumference`.
145    pub fn validate(&self) -> Result<(), (&'static str, String)> {
146        match self {
147            Self::Linear { min, max } => {
148                if !min.is_finite() || !max.is_finite() {
149                    return Err((
150                        "line.range",
151                        format!("min/max must be finite (got min={min}, max={max})"),
152                    ));
153                }
154                if min > max {
155                    return Err(("line.range", format!("min ({min}) must be <= max ({max})")));
156                }
157            }
158            #[cfg(feature = "loop_lines")]
159            Self::Loop {
160                circumference,
161                min_headway,
162            } => {
163                if !circumference.is_finite() || *circumference <= 0.0 {
164                    return Err((
165                        "line.kind",
166                        format!("loop circumference must be finite and > 0 (got {circumference})"),
167                    ));
168                }
169                // Negative `min_headway` would make `headway_clamp_target`
170                // compute `safe_advance = gap - min_headway = gap + |min_headway|`,
171                // i.e. the trailer would be allowed to advance *past* the
172                // leader. Reject up front so the cross-field guard
173                // (`max_cars * min_headway <= circumference`) can't be
174                // bypassed by sign-flipping `required` to negative.
175                if !min_headway.is_finite() || *min_headway <= 0.0 {
176                    return Err((
177                        "line.kind",
178                        format!("loop min_headway must be finite and > 0 (got {min_headway})"),
179                    ));
180                }
181            }
182        }
183        Ok(())
184    }
185}
186
187/// Component for a line entity — the physical path an elevator car travels.
188///
189/// In a building this is a hoistway/shaft. For a space elevator it is a
190/// tether or cable. For a metro or people-mover (with the `loop_lines`
191/// feature) it is a closed loop. The term "line" is domain-neutral.
192///
193/// A line belongs to exactly one [`GroupId`] at a time but can be
194/// reassigned at runtime (swing-car pattern). Multiple cars may share
195/// a line (multi-car shafts); collision avoidance is left to game hooks
196/// for `Linear` lines and enforced by headway clamping for `Loop` lines.
197///
198/// Intrinsic properties only — relationship data (which elevators, which
199/// stops) lives in [`LineInfo`](crate::dispatch::LineInfo) on the
200/// [`ElevatorGroup`](crate::dispatch::ElevatorGroup).
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202#[serde(from = "LineWire", into = "LineWire")]
203pub struct Line {
204    /// Human-readable name.
205    pub(crate) name: String,
206    /// Dispatch group this line currently belongs to.
207    pub(crate) group: GroupId,
208    /// Physical orientation (metadata for rendering).
209    pub(crate) orientation: Orientation,
210    /// Optional floor-plan position (for spatial queries / rendering).
211    pub(crate) position: Option<SpatialPosition>,
212    /// Topology kind — open-ended linear axis or closed loop.
213    pub(crate) kind: LineKind,
214    /// Maximum number of cars allowed on this line (None = unlimited).
215    pub(crate) max_cars: Option<usize>,
216}
217
218/// On-the-wire representation of [`Line`]. Bridges the legacy flat
219/// `min_position` / `max_position` fields with the new `kind` field
220/// during the transitional release: snapshots written by builds that
221/// haven't migrated yet still deserialize correctly, and snapshots
222/// from this build can still be inspected by older tooling.
223///
224/// On serialize we emit `kind` *and* derived flat fields. On deserialize,
225/// `kind` is preferred; flat fields are the fallback.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227struct LineWire {
228    /// Human-readable name (always present in both legacy and current shapes).
229    name: String,
230    /// Dispatch group ownership.
231    group: GroupId,
232    /// Physical orientation (defaults to Vertical if absent).
233    #[serde(default)]
234    orientation: Orientation,
235    /// 2D anchor on the floor plan (defaults to None).
236    #[serde(default)]
237    position: Option<SpatialPosition>,
238    /// New topology field — preferred when present.
239    #[serde(default)]
240    kind: Option<LineKind>,
241    /// Legacy flat field — fallback when `kind` is absent.
242    #[serde(default)]
243    min_position: Option<f64>,
244    /// Legacy flat field — fallback when `kind` is absent.
245    #[serde(default)]
246    max_position: Option<f64>,
247    /// Maximum cars on this line.
248    #[serde(default)]
249    max_cars: Option<usize>,
250}
251
252impl From<LineWire> for Line {
253    fn from(w: LineWire) -> Self {
254        let kind = w.kind.unwrap_or_else(|| LineKind::Linear {
255            min: w.min_position.unwrap_or(0.0),
256            max: w.max_position.unwrap_or(0.0),
257        });
258        Self {
259            name: w.name,
260            group: w.group,
261            orientation: w.orientation,
262            position: w.position,
263            kind,
264            max_cars: w.max_cars,
265        }
266    }
267}
268
269impl From<Line> for LineWire {
270    fn from(l: Line) -> Self {
271        let (min_position, max_position) = match &l.kind {
272            LineKind::Linear { min, max } => (Some(*min), Some(*max)),
273            #[cfg(feature = "loop_lines")]
274            LineKind::Loop { circumference, .. } => (Some(0.0), Some(*circumference)),
275        };
276        Self {
277            name: l.name,
278            group: l.group,
279            orientation: l.orientation,
280            position: l.position,
281            kind: Some(l.kind),
282            min_position,
283            max_position,
284            max_cars: l.max_cars,
285        }
286    }
287}
288
289impl Line {
290    /// Human-readable name.
291    #[must_use]
292    pub fn name(&self) -> &str {
293        &self.name
294    }
295
296    /// Dispatch group this line currently belongs to.
297    #[must_use]
298    pub const fn group(&self) -> GroupId {
299        self.group
300    }
301
302    /// Physical orientation.
303    #[must_use]
304    pub const fn orientation(&self) -> Orientation {
305        self.orientation
306    }
307
308    /// Optional floor-plan position. For [`LineKind::Loop`] this is the
309    /// geometric *center* of the loop; hosts derive a rendering radius
310    /// from [`Self::circumference`].
311    #[must_use]
312    pub const fn position(&self) -> Option<&SpatialPosition> {
313        self.position.as_ref()
314    }
315
316    /// Topology kind — linear axis or closed loop.
317    #[must_use]
318    pub const fn kind(&self) -> &LineKind {
319        &self.kind
320    }
321
322    /// Shorthand for `self.kind().is_loop()`.
323    #[must_use]
324    pub const fn is_loop(&self) -> bool {
325        self.kind.is_loop()
326    }
327
328    /// Lowest reachable position on a [`LineKind::Linear`] line. Returns
329    /// `None` for [`LineKind::Loop`] — loops have no endpoints.
330    ///
331    /// Replaces the former `min_position()` accessor. Callers that
332    /// blindly dereferenced the old `f64` should now decide whether
333    /// they want Linear-only behavior (`linear_min().expect("linear")`)
334    /// or to handle Loop explicitly.
335    #[must_use]
336    pub const fn linear_min(&self) -> Option<f64> {
337        match self.kind {
338            LineKind::Linear { min, .. } => Some(min),
339            #[cfg(feature = "loop_lines")]
340            LineKind::Loop { .. } => None,
341        }
342    }
343
344    /// Highest reachable position on a [`LineKind::Linear`] line. Returns
345    /// `None` for [`LineKind::Loop`].
346    #[must_use]
347    pub const fn linear_max(&self) -> Option<f64> {
348        match self.kind {
349            LineKind::Linear { max, .. } => Some(max),
350            #[cfg(feature = "loop_lines")]
351            LineKind::Loop { .. } => None,
352        }
353    }
354
355    /// Total path length of a [`LineKind::Loop`] line. Returns `None`
356    /// for [`LineKind::Linear`].
357    #[must_use]
358    pub const fn circumference(&self) -> Option<f64> {
359        match self.kind {
360            LineKind::Linear { .. } => None,
361            #[cfg(feature = "loop_lines")]
362            LineKind::Loop { circumference, .. } => Some(circumference),
363        }
364    }
365
366    /// Minimum forward distance between successive cars on a
367    /// [`LineKind::Loop`] line. Returns `None` for [`LineKind::Linear`].
368    #[must_use]
369    pub const fn min_headway(&self) -> Option<f64> {
370        match self.kind {
371            LineKind::Linear { .. } => None,
372            #[cfg(feature = "loop_lines")]
373            LineKind::Loop { min_headway, .. } => Some(min_headway),
374        }
375    }
376
377    /// Maximum number of cars allowed on this line.
378    #[must_use]
379    pub const fn max_cars(&self) -> Option<usize> {
380        self.max_cars
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn linear_accessors_return_some() {
390        let line = Line::from(LineWire {
391            name: "L1".into(),
392            group: GroupId(0),
393            orientation: Orientation::Vertical,
394            position: None,
395            kind: Some(LineKind::Linear {
396                min: 0.0,
397                max: 100.0,
398            }),
399            min_position: None,
400            max_position: None,
401            max_cars: None,
402        });
403        assert_eq!(line.linear_min(), Some(0.0));
404        assert_eq!(line.linear_max(), Some(100.0));
405        assert_eq!(line.circumference(), None);
406        assert_eq!(line.min_headway(), None);
407        assert!(!line.is_loop());
408    }
409
410    #[test]
411    fn legacy_flat_fields_construct_linear_kind() {
412        let line = Line::from(LineWire {
413            name: "L1".into(),
414            group: GroupId(0),
415            orientation: Orientation::Vertical,
416            position: None,
417            kind: None,
418            min_position: Some(0.0),
419            max_position: Some(50.0),
420            max_cars: None,
421        });
422        assert_eq!(
423            line.kind(),
424            &LineKind::Linear {
425                min: 0.0,
426                max: 50.0
427            }
428        );
429    }
430
431    #[test]
432    #[allow(clippy::unwrap_used, reason = "test helper")]
433    fn round_trip_writes_both_kind_and_flat_fields() {
434        let line = Line {
435            name: "L1".into(),
436            group: GroupId(0),
437            orientation: Orientation::Vertical,
438            position: None,
439            kind: LineKind::Linear {
440                min: 0.0,
441                max: 75.0,
442            },
443            max_cars: None,
444        };
445        let serialized = serde_json::to_value(&line).unwrap();
446        // Both shapes must be present so an older deserializer can still read it.
447        assert!(serialized.get("kind").is_some());
448        assert_eq!(
449            serialized
450                .get("min_position")
451                .and_then(serde_json::Value::as_f64),
452            Some(0.0)
453        );
454        assert_eq!(
455            serialized
456                .get("max_position")
457                .and_then(serde_json::Value::as_f64),
458            Some(75.0)
459        );
460
461        let deserialized: Line = serde_json::from_value(serialized).unwrap();
462        assert_eq!(deserialized.kind(), line.kind());
463    }
464
465    #[test]
466    fn validate_rejects_non_finite_linear() {
467        assert!(
468            LineKind::Linear {
469                min: f64::NAN,
470                max: 10.0
471            }
472            .validate()
473            .is_err()
474        );
475        assert!(
476            LineKind::Linear {
477                min: 5.0,
478                max: f64::INFINITY
479            }
480            .validate()
481            .is_err()
482        );
483    }
484
485    #[test]
486    fn validate_rejects_inverted_linear_bounds() {
487        assert!(
488            LineKind::Linear {
489                min: 10.0,
490                max: 5.0
491            }
492            .validate()
493            .is_err()
494        );
495    }
496
497    #[test]
498    fn validate_accepts_well_formed_linear() {
499        assert!(
500            LineKind::Linear {
501                min: 0.0,
502                max: 100.0
503            }
504            .validate()
505            .is_ok()
506        );
507    }
508
509    #[cfg(feature = "loop_lines")]
510    #[test]
511    fn validate_rejects_non_positive_circumference() {
512        assert!(
513            LineKind::Loop {
514                circumference: 0.0,
515                min_headway: 5.0
516            }
517            .validate()
518            .is_err()
519        );
520        assert!(
521            LineKind::Loop {
522                circumference: -1.0,
523                min_headway: 5.0
524            }
525            .validate()
526            .is_err()
527        );
528        assert!(
529            LineKind::Loop {
530                circumference: f64::NAN,
531                min_headway: 5.0
532            }
533            .validate()
534            .is_err()
535        );
536    }
537
538    #[cfg(feature = "loop_lines")]
539    #[test]
540    fn validate_accepts_positive_circumference() {
541        assert!(
542            LineKind::Loop {
543                circumference: 100.0,
544                min_headway: 5.0
545            }
546            .validate()
547            .is_ok()
548        );
549    }
550
551    #[cfg(feature = "loop_lines")]
552    #[test]
553    fn validate_rejects_non_positive_min_headway() {
554        // Negative `min_headway` would let `headway_clamp_target` compute
555        // a `safe_advance` larger than the actual gap, allowing overtaking.
556        for bad in [0.0_f64, -1.0, f64::NAN] {
557            let result = LineKind::Loop {
558                circumference: 100.0,
559                min_headway: bad,
560            }
561            .validate();
562            assert!(
563                result.is_err(),
564                "min_headway={bad} should have been rejected, got {result:?}",
565            );
566        }
567    }
568
569    #[cfg(feature = "loop_lines")]
570    #[test]
571    fn loop_accessors_return_some() {
572        let line = Line::from(LineWire {
573            name: "L1".into(),
574            group: GroupId(0),
575            orientation: Orientation::Horizontal,
576            position: None,
577            kind: Some(LineKind::Loop {
578                circumference: 200.0,
579                min_headway: 10.0,
580            }),
581            min_position: None,
582            max_position: None,
583            max_cars: None,
584        });
585        assert_eq!(line.linear_min(), None);
586        assert_eq!(line.linear_max(), None);
587        assert_eq!(line.circumference(), Some(200.0));
588        assert_eq!(line.min_headway(), Some(10.0));
589        assert!(line.is_loop());
590    }
591}