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}