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}