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