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