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