Skip to main content

elevator_core/sim/
topology.rs

1//! Dynamic topology mutation and queries.
2//!
3//! Add/remove/reassign lines, elevators, stops, and groups at runtime, plus
4//! read-only topology queries (reachability, shortest route, transfer
5//! points). Split out from `sim.rs` to keep each concern readable.
6
7use crate::components::Route;
8use crate::components::{Elevator, ElevatorPhase, Line, Position, Stop, Velocity};
9use crate::dispatch::{BuiltinStrategy, DispatchStrategy, ElevatorGroup, LineInfo};
10use crate::door::DoorState;
11use crate::entity::EntityId;
12use crate::error::SimError;
13use crate::events::Event;
14use crate::ids::GroupId;
15use crate::topology::TopologyGraph;
16
17use super::{ElevatorParams, LineParams, Simulation};
18
19impl Simulation {
20    // ── Dynamic topology ────────────────────────────────────────────
21
22    /// Mark the topology graph dirty so it is rebuilt on next query.
23    fn mark_topo_dirty(&self) {
24        if let Ok(mut g) = self.topo_graph.lock() {
25            g.mark_dirty();
26        }
27    }
28
29    /// Find the (`group_index`, `line_index`) for a line entity.
30    fn find_line(&self, line: EntityId) -> Result<(usize, usize), SimError> {
31        self.groups
32            .iter()
33            .enumerate()
34            .find_map(|(gi, g)| {
35                g.lines()
36                    .iter()
37                    .position(|li| li.entity() == line)
38                    .map(|li_idx| (gi, li_idx))
39            })
40            .ok_or(SimError::LineNotFound(line))
41    }
42
43    /// Add a new stop to a group at runtime. Returns its `EntityId`.
44    ///
45    /// Runtime-added stops have no `StopId` — they are identified purely
46    /// by `EntityId`. The `stop_lookup` (config `StopId` → `EntityId`)
47    /// is not updated.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`SimError::LineNotFound`] if the line entity does not exist.
52    pub fn add_stop(
53        &mut self,
54        name: String,
55        position: f64,
56        line: EntityId,
57    ) -> Result<EntityId, SimError> {
58        if !position.is_finite() {
59            return Err(SimError::InvalidConfig {
60                field: "position",
61                reason: format!(
62                    "stop position must be finite (got {position}); NaN/±inf \
63                     corrupt SortedStops ordering and find_stop_at_position lookup"
64                ),
65            });
66        }
67
68        let group_id = self
69            .world
70            .line(line)
71            .map(|l| l.group)
72            .ok_or(SimError::LineNotFound(line))?;
73
74        let (group_idx, line_idx) = self.find_line(line)?;
75
76        let eid = self.world.spawn();
77        self.world.set_stop(eid, Stop { name, position });
78        self.world.set_position(eid, Position { value: position });
79
80        // Add to the line's serves list.
81        self.groups[group_idx].lines_mut()[line_idx]
82            .serves_mut()
83            .push(eid);
84
85        // Add to the group's flat cache.
86        self.groups[group_idx].push_stop(eid);
87
88        // Maintain sorted-stops index for O(log n) PassingFloor detection.
89        if let Some(sorted) = self.world.resource_mut::<crate::world::SortedStops>() {
90            let idx = sorted.0.partition_point(|&(p, _)| p < position);
91            sorted.0.insert(idx, (position, eid));
92        }
93
94        self.mark_topo_dirty();
95        self.events.emit(Event::StopAdded {
96            stop: eid,
97            line,
98            group: group_id,
99            tick: self.tick,
100        });
101        Ok(eid)
102    }
103
104    /// Add a new elevator to a line at runtime. Returns its `EntityId`.
105    ///
106    /// # Errors
107    ///
108    /// Returns [`SimError::LineNotFound`] if the line entity does not exist.
109    pub fn add_elevator(
110        &mut self,
111        params: &ElevatorParams,
112        line: EntityId,
113        starting_position: f64,
114    ) -> Result<EntityId, SimError> {
115        // Reject malformed params before they reach the world. Without this,
116        // zero/negative physics or zero door ticks crash later phases.
117        super::construction::validate_elevator_physics(
118            params.max_speed.value(),
119            params.acceleration.value(),
120            params.deceleration.value(),
121            params.weight_capacity.value(),
122            params.inspection_speed_factor,
123            params.door_transition_ticks,
124            params.door_open_ticks,
125            params.bypass_load_up_pct,
126            params.bypass_load_down_pct,
127        )?;
128        if !starting_position.is_finite() {
129            return Err(SimError::InvalidConfig {
130                field: "starting_position",
131                reason: format!(
132                    "must be finite (got {starting_position}); NaN/±inf corrupt \
133                     SortedStops ordering and find_stop_at_position lookup"
134                ),
135            });
136        }
137
138        let group_id = self
139            .world
140            .line(line)
141            .map(|l| l.group)
142            .ok_or(SimError::LineNotFound(line))?;
143
144        let (group_idx, line_idx) = self.find_line(line)?;
145
146        // Enforce max_cars limit.
147        if let Some(max) = self.world.line(line).and_then(Line::max_cars) {
148            let current_count = self.groups[group_idx].lines()[line_idx].elevators().len();
149            if current_count >= max {
150                return Err(SimError::InvalidConfig {
151                    field: "line.max_cars",
152                    reason: format!("line already has {current_count} cars (max {max})"),
153                });
154            }
155        }
156
157        let eid = self.world.spawn();
158        self.world.set_position(
159            eid,
160            Position {
161                value: starting_position,
162            },
163        );
164        self.world.set_velocity(eid, Velocity { value: 0.0 });
165        self.world.set_elevator(
166            eid,
167            Elevator {
168                phase: ElevatorPhase::Idle,
169                door: DoorState::Closed,
170                max_speed: params.max_speed,
171                acceleration: params.acceleration,
172                deceleration: params.deceleration,
173                weight_capacity: params.weight_capacity,
174                current_load: crate::components::Weight::ZERO,
175                riders: Vec::new(),
176                target_stop: None,
177                door_transition_ticks: params.door_transition_ticks,
178                door_open_ticks: params.door_open_ticks,
179                line,
180                repositioning: false,
181                restricted_stops: params.restricted_stops.clone(),
182                inspection_speed_factor: params.inspection_speed_factor,
183                going_up: true,
184                going_down: true,
185                move_count: 0,
186                door_command_queue: Vec::new(),
187                manual_target_velocity: None,
188                bypass_load_up_pct: params.bypass_load_up_pct,
189                bypass_load_down_pct: params.bypass_load_down_pct,
190                home_stop: None,
191            },
192        );
193        self.world
194            .set_destination_queue(eid, crate::components::DestinationQueue::new());
195        self.groups[group_idx].lines_mut()[line_idx]
196            .elevators_mut()
197            .push(eid);
198        self.groups[group_idx].push_elevator(eid);
199
200        // Tag the elevator with its line's "line:{name}" tag.
201        let line_name = self.world.line(line).map(|l| l.name.clone());
202        if let Some(name) = line_name
203            && let Some(tags) = self
204                .world
205                .resource_mut::<crate::tagged_metrics::MetricTags>()
206        {
207            tags.tag(eid, format!("line:{name}"));
208        }
209
210        self.mark_topo_dirty();
211        self.events.emit(Event::ElevatorAdded {
212            elevator: eid,
213            line,
214            group: group_id,
215            tick: self.tick,
216        });
217        Ok(eid)
218    }
219
220    // ── Line / group topology ───────────────────────────────────────
221
222    /// Add a new line to a group. Returns the line entity.
223    ///
224    /// # Errors
225    ///
226    /// Returns [`SimError::GroupNotFound`] if the specified group does not exist.
227    /// Returns [`SimError::InvalidConfig`] if `min_position` / `max_position`
228    /// is non-finite or `min_position > max_position` — broken bounds
229    /// would produce NaN positions on every car added to the line.
230    pub fn add_line(&mut self, params: &LineParams) -> Result<EntityId, SimError> {
231        if !params.min_position.is_finite() || !params.max_position.is_finite() {
232            return Err(SimError::InvalidConfig {
233                field: "line.range",
234                reason: format!(
235                    "min/max must be finite (got min={}, max={})",
236                    params.min_position, params.max_position
237                ),
238            });
239        }
240        if params.min_position > params.max_position {
241            return Err(SimError::InvalidConfig {
242                field: "line.range",
243                reason: format!(
244                    "min ({}) must be <= max ({})",
245                    params.min_position, params.max_position
246                ),
247            });
248        }
249
250        let group_id = params.group;
251        let group = self
252            .groups
253            .iter_mut()
254            .find(|g| g.id() == group_id)
255            .ok_or(SimError::GroupNotFound(group_id))?;
256
257        let line_tag = format!("line:{}", params.name);
258
259        let eid = self.world.spawn();
260        self.world.set_line(
261            eid,
262            Line {
263                name: params.name.clone(),
264                group: group_id,
265                orientation: params.orientation,
266                position: params.position,
267                min_position: params.min_position,
268                max_position: params.max_position,
269                max_cars: params.max_cars,
270            },
271        );
272
273        group
274            .lines_mut()
275            .push(LineInfo::new(eid, Vec::new(), Vec::new()));
276
277        // Tag the line entity with "line:{name}" for per-line metrics.
278        if let Some(tags) = self
279            .world
280            .resource_mut::<crate::tagged_metrics::MetricTags>()
281        {
282            tags.tag(eid, line_tag);
283        }
284
285        self.mark_topo_dirty();
286        self.events.emit(Event::LineAdded {
287            line: eid,
288            group: group_id,
289            tick: self.tick,
290        });
291        Ok(eid)
292    }
293
294    /// Set the reachable position range of a line.
295    ///
296    /// Cars whose current position falls outside the new `[min, max]` are
297    /// clamped to the boundary. Phase is left untouched — a car mid-travel
298    /// keeps `MovingToStop` and the movement system reconciles on the
299    /// next tick.
300    ///
301    /// # Errors
302    ///
303    /// Returns [`SimError::LineNotFound`] if the line entity does not exist.
304    /// Returns [`SimError::InvalidConfig`] if `min` or `max` is non-finite
305    /// or `min > max`.
306    pub fn set_line_range(&mut self, line: EntityId, min: f64, max: f64) -> Result<(), SimError> {
307        if !min.is_finite() || !max.is_finite() {
308            return Err(SimError::InvalidConfig {
309                field: "line.range",
310                reason: format!("min/max must be finite (got min={min}, max={max})"),
311            });
312        }
313        if min > max {
314            return Err(SimError::InvalidConfig {
315                field: "line.range",
316                reason: format!("min ({min}) must be <= max ({max})"),
317            });
318        }
319        let line_ref = self
320            .world
321            .line_mut(line)
322            .ok_or(SimError::LineNotFound(line))?;
323        line_ref.min_position = min;
324        line_ref.max_position = max;
325
326        // Clamp any cars on this line whose position falls outside the new range.
327        let car_ids: Vec<EntityId> = self
328            .world
329            .iter_elevators()
330            .filter_map(|(eid, _, car)| (car.line == line).then_some(eid))
331            .collect();
332        for eid in car_ids {
333            // Skip cars without a Position component — clamping requires
334            // a real reading, and writing velocity alone (without a
335            // matching position update) would silently desync the two.
336            let Some(pos) = self.world.position(eid).map(|p| p.value) else {
337                continue;
338            };
339            if pos < min || pos > max {
340                let clamped = pos.clamp(min, max);
341                if let Some(p) = self.world.position_mut(eid) {
342                    p.value = clamped;
343                }
344                if let Some(v) = self.world.velocity_mut(eid) {
345                    v.value = 0.0;
346                }
347            }
348        }
349
350        self.mark_topo_dirty();
351        Ok(())
352    }
353
354    /// Remove a line and all its elevators from the simulation.
355    ///
356    /// Elevators on the line are disabled (not despawned) so riders are
357    /// properly ejected to the nearest stop.
358    ///
359    /// # Errors
360    ///
361    /// Returns [`SimError::LineNotFound`] if the line entity is not found
362    /// in any group.
363    pub fn remove_line(&mut self, line: EntityId) -> Result<(), SimError> {
364        let (group_idx, line_idx) = self.find_line(line)?;
365
366        let group_id = self.groups[group_idx].id();
367
368        // Collect elevator entities to disable.
369        let elevator_ids: Vec<EntityId> = self.groups[group_idx].lines()[line_idx]
370            .elevators()
371            .to_vec();
372
373        // Disable each elevator (ejects riders properly).
374        for eid in &elevator_ids {
375            // Ignore errors from already-disabled elevators.
376            let _ = self.disable(*eid);
377        }
378
379        // Remove the LineInfo from the group.
380        self.groups[group_idx].lines_mut().remove(line_idx);
381
382        // Rebuild flat caches.
383        self.groups[group_idx].rebuild_caches();
384
385        // Remove Line component from world.
386        self.world.remove_line(line);
387
388        self.mark_topo_dirty();
389        self.events.emit(Event::LineRemoved {
390            line,
391            group: group_id,
392            tick: self.tick,
393        });
394        Ok(())
395    }
396
397    /// Remove an elevator from the simulation.
398    ///
399    /// The elevator is disabled first (ejecting any riders), then removed
400    /// from its line and despawned from the world.
401    ///
402    /// # Errors
403    ///
404    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
405    pub fn remove_elevator(&mut self, elevator: EntityId) -> Result<(), SimError> {
406        let line = self
407            .world
408            .elevator(elevator)
409            .ok_or(SimError::EntityNotFound(elevator))?
410            .line();
411
412        // Disable first to eject riders and reset state.
413        let _ = self.disable(elevator);
414
415        // Find and remove from group/line topology. If `find_line` fails
416        // the elevator's `line` ref points at a removed/moved line — an
417        // inconsistent state, but we still want to despawn for cleanup.
418        //
419        // The `disable` call above already fired `notify_removed` on the
420        // group's dispatcher — the cache still includes the elevator at
421        // that point — so no additional notify is needed here. Custom
422        // `DispatchStrategy::notify_removed` impls that count invocations
423        // (e.g. tests with an `AtomicUsize`) can assume exactly one call
424        // per removal.
425        let resolved_group: Option<GroupId> = match self.find_line(line) {
426            Ok((group_idx, line_idx)) => {
427                self.groups[group_idx].lines_mut()[line_idx]
428                    .elevators_mut()
429                    .retain(|&e| e != elevator);
430                self.groups[group_idx].rebuild_caches();
431                Some(self.groups[group_idx].id())
432            }
433            Err(_) => None,
434        };
435
436        // Only emit ElevatorRemoved when we resolved the actual group.
437        // Pre-fix this fired with `GroupId(0)` as a sentinel, masquerading
438        // a dangling-line cleanup as a legitimate group-0 removal (#266).
439        if let Some(group_id) = resolved_group {
440            self.events.emit(Event::ElevatorRemoved {
441                elevator,
442                line,
443                group: group_id,
444                tick: self.tick,
445            });
446        }
447
448        // Despawn from world.
449        self.world.despawn(elevator);
450
451        self.mark_topo_dirty();
452        Ok(())
453    }
454
455    /// Remove a stop from the simulation.
456    ///
457    /// The stop is disabled first (invalidating routes that reference it),
458    /// then removed from all lines and despawned from the world.
459    ///
460    /// # Errors
461    ///
462    /// Returns [`SimError::EntityNotFound`] if the stop does not exist.
463    pub fn remove_stop(&mut self, stop: EntityId) -> Result<(), SimError> {
464        if self.world.stop(stop).is_none() {
465            return Err(SimError::EntityNotFound(stop));
466        }
467
468        // Warn if resident riders exist at the stop before we disable it
469        // (disabling will abandon them, clearing the residents index).
470        let residents: Vec<EntityId> = self
471            .rider_index
472            .residents_at(stop)
473            .iter()
474            .copied()
475            .collect();
476        if !residents.is_empty() {
477            self.events
478                .emit(Event::ResidentsAtRemovedStop { stop, residents });
479        }
480
481        // Disable first to invalidate routes referencing this stop.
482        // Use the stop-specific helper so route-invalidation events
483        // carry `StopRemoved` rather than `StopDisabled`.
484        self.disable_stop_inner(stop, true);
485        self.world.disable(stop);
486        self.events.emit(Event::EntityDisabled {
487            entity: stop,
488            tick: self.tick,
489        });
490
491        // Scrub references to the removed stop from every elevator so the
492        // post-despawn tick loop does not chase a dead EntityId through
493        // `target_stop`, the destination queue, or access-control checks.
494        let elevator_ids: Vec<EntityId> =
495            self.world.iter_elevators().map(|(eid, _, _)| eid).collect();
496        for eid in elevator_ids {
497            if let Some(car) = self.world.elevator_mut(eid) {
498                if car.target_stop == Some(stop) {
499                    car.target_stop = None;
500                }
501                car.restricted_stops.remove(&stop);
502            }
503            if let Some(q) = self.world.destination_queue_mut(eid) {
504                q.retain(|s| s != stop);
505            }
506            // Drop any car-call whose floor is the removed stop. Built-in
507            // strategies don't currently route on car_calls but the public
508            // `sim.car_calls(car)` accessor and custom strategies (via
509            // `car_calls_for`) would otherwise return dangling refs (#293).
510            if let Some(calls) = self.world.car_calls_mut(eid) {
511                calls.retain(|c| c.floor != stop);
512            }
513        }
514
515        // Remove from all lines and groups.
516        for group in &mut self.groups {
517            for line_info in group.lines_mut() {
518                line_info.serves_mut().retain(|&s| s != stop);
519            }
520            group.rebuild_caches();
521        }
522
523        // Remove from SortedStops resource.
524        if let Some(sorted) = self.world.resource_mut::<crate::world::SortedStops>() {
525            sorted.0.retain(|&(_, s)| s != stop);
526        }
527
528        // Remove from stop_lookup.
529        self.stop_lookup.retain(|_, &mut eid| eid != stop);
530
531        self.events.emit(Event::StopRemoved {
532            stop,
533            tick: self.tick,
534        });
535
536        // Despawn from world.
537        self.world.despawn(stop);
538
539        // Rebuild the rider index to evict any stale per-stop entries
540        // pointing at the despawned stop. Cheap (O(riders)) and the only
541        // safe option once the stop EntityId is gone.
542        self.rider_index.rebuild(&self.world);
543
544        self.mark_topo_dirty();
545        Ok(())
546    }
547
548    /// Create a new dispatch group. Returns the group ID.
549    pub fn add_group(
550        &mut self,
551        name: impl Into<String>,
552        dispatch: impl DispatchStrategy + 'static,
553    ) -> GroupId {
554        let next_id = self
555            .groups
556            .iter()
557            .map(|g| g.id().0)
558            .max()
559            .map_or(0, |m| m + 1);
560        let group_id = GroupId(next_id);
561
562        self.groups
563            .push(ElevatorGroup::new(group_id, name.into(), Vec::new()));
564
565        self.dispatchers.insert(group_id, Box::new(dispatch));
566        self.strategy_ids.insert(group_id, BuiltinStrategy::Scan);
567        self.mark_topo_dirty();
568        group_id
569    }
570
571    /// Reassign a line to a different group. Returns the old `GroupId`.
572    ///
573    /// # Errors
574    ///
575    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
576    /// Returns [`SimError::GroupNotFound`] if `new_group` does not exist.
577    pub fn assign_line_to_group(
578        &mut self,
579        line: EntityId,
580        new_group: GroupId,
581    ) -> Result<GroupId, SimError> {
582        let (old_group_idx, line_idx) = self.find_line(line)?;
583
584        // Verify new group exists.
585        if !self.groups.iter().any(|g| g.id() == new_group) {
586            return Err(SimError::GroupNotFound(new_group));
587        }
588
589        let old_group_id = self.groups[old_group_idx].id();
590
591        // Same-group reassign is a no-op. Skip BEFORE the notify_removed
592        // calls or we'd needlessly clear each elevator's dispatcher state
593        // (direction tracking in SCAN/LOOK, etc.) on a redundant move.
594        // Matches the early-return pattern in `reassign_elevator_to_line`.
595        if old_group_id == new_group {
596            return Ok(old_group_id);
597        }
598
599        // Notify the old dispatcher that these elevators are leaving — its
600        // per-elevator state (e.g. ScanDispatch.direction keyed by EntityId)
601        // would otherwise leak indefinitely as lines move between groups.
602        // Mirrors the cleanup `reassign_elevator_to_line` already does. (#257)
603        let elevators_to_notify: Vec<EntityId> = self.groups[old_group_idx].lines()[line_idx]
604            .elevators()
605            .to_vec();
606        if let Some(dispatcher) = self.dispatchers.get_mut(&old_group_id) {
607            for eid in &elevators_to_notify {
608                dispatcher.notify_removed(*eid);
609            }
610        }
611
612        // Remove LineInfo from old group.
613        let line_info = self.groups[old_group_idx].lines_mut().remove(line_idx);
614        self.groups[old_group_idx].rebuild_caches();
615
616        // Re-lookup new_group_idx by ID — we didn't capture it before the
617        // mutation. (Removal of a `LineInfo` from a group's inner `lines`
618        // vec doesn't shift `self.groups` indices, so this is purely about
619        // not having stored the index earlier, not about index invalidation.)
620        let new_group_idx = self
621            .groups
622            .iter()
623            .position(|g| g.id() == new_group)
624            .ok_or(SimError::GroupNotFound(new_group))?;
625        self.groups[new_group_idx].lines_mut().push(line_info);
626        self.groups[new_group_idx].rebuild_caches();
627
628        // Update Line component's group field.
629        if let Some(line_comp) = self.world.line_mut(line) {
630            line_comp.group = new_group;
631        }
632
633        self.mark_topo_dirty();
634        self.events.emit(Event::LineReassigned {
635            line,
636            old_group: old_group_id,
637            new_group,
638            tick: self.tick,
639        });
640
641        Ok(old_group_id)
642    }
643
644    /// Reassign an elevator to a different line (swing-car pattern).
645    ///
646    /// The elevator is moved from its current line to the target line.
647    /// Both lines must be in the same group, or you must reassign the
648    /// line first via [`assign_line_to_group`](Self::assign_line_to_group).
649    ///
650    /// # Errors
651    ///
652    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
653    /// Returns [`SimError::LineNotFound`] if the target line is not found in any group.
654    pub fn reassign_elevator_to_line(
655        &mut self,
656        elevator: EntityId,
657        new_line: EntityId,
658    ) -> Result<(), SimError> {
659        let old_line = self
660            .world
661            .elevator(elevator)
662            .ok_or(SimError::EntityNotFound(elevator))?
663            .line();
664
665        if old_line == new_line {
666            return Ok(());
667        }
668
669        // Validate both lines exist BEFORE mutating anything.
670        let (old_group_idx, old_line_idx) = self.find_line(old_line)?;
671        let (new_group_idx, new_line_idx) = self.find_line(new_line)?;
672
673        // Enforce max_cars on target line.
674        if let Some(max) = self.world.line(new_line).and_then(Line::max_cars) {
675            let current_count = self.groups[new_group_idx].lines()[new_line_idx]
676                .elevators()
677                .len();
678            if current_count >= max {
679                return Err(SimError::InvalidConfig {
680                    field: "line.max_cars",
681                    reason: format!("target line already has {current_count} cars (max {max})"),
682                });
683            }
684        }
685
686        let old_group_id = self.groups[old_group_idx].id();
687        let new_group_id = self.groups[new_group_idx].id();
688
689        self.groups[old_group_idx].lines_mut()[old_line_idx]
690            .elevators_mut()
691            .retain(|&e| e != elevator);
692        self.groups[new_group_idx].lines_mut()[new_line_idx]
693            .elevators_mut()
694            .push(elevator);
695
696        if let Some(car) = self.world.elevator_mut(elevator) {
697            car.line = new_line;
698        }
699
700        self.groups[old_group_idx].rebuild_caches();
701        if new_group_idx != old_group_idx {
702            self.groups[new_group_idx].rebuild_caches();
703
704            // Notify the old group's dispatcher so it clears per-elevator
705            // state (ScanDispatch/LookDispatch track direction by
706            // EntityId). Matches the symmetry with `remove_elevator`.
707            if let Some(old_dispatcher) = self.dispatchers.get_mut(&old_group_id) {
708                old_dispatcher.notify_removed(elevator);
709            }
710        }
711
712        self.mark_topo_dirty();
713
714        let _ = new_group_id; // reserved for symmetric notify_added once the trait gains one
715        self.events.emit(Event::ElevatorReassigned {
716            elevator,
717            old_line,
718            new_line,
719            tick: self.tick,
720        });
721
722        Ok(())
723    }
724
725    /// Add a stop to a line's served stops.
726    ///
727    /// # Errors
728    ///
729    /// Returns [`SimError::EntityNotFound`] if the stop does not exist.
730    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
731    pub fn add_stop_to_line(&mut self, stop: EntityId, line: EntityId) -> Result<(), SimError> {
732        // Verify stop exists.
733        if self.world.stop(stop).is_none() {
734            return Err(SimError::EntityNotFound(stop));
735        }
736
737        let (group_idx, line_idx) = self.find_line(line)?;
738
739        let li = &mut self.groups[group_idx].lines_mut()[line_idx];
740        if !li.serves().contains(&stop) {
741            li.serves_mut().push(stop);
742        }
743
744        self.groups[group_idx].push_stop(stop);
745
746        self.mark_topo_dirty();
747        Ok(())
748    }
749
750    /// Remove a stop from a line's served stops.
751    ///
752    /// # Errors
753    ///
754    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
755    pub fn remove_stop_from_line(
756        &mut self,
757        stop: EntityId,
758        line: EntityId,
759    ) -> Result<(), SimError> {
760        let (group_idx, line_idx) = self.find_line(line)?;
761
762        self.groups[group_idx].lines_mut()[line_idx]
763            .serves_mut()
764            .retain(|&s| s != stop);
765
766        // Rebuild group's stop_entities from all lines.
767        self.groups[group_idx].rebuild_caches();
768
769        self.mark_topo_dirty();
770        Ok(())
771    }
772
773    // ── Line / group queries ────────────────────────────────────────
774
775    /// Get all line entities across all groups.
776    #[must_use]
777    pub fn all_lines(&self) -> Vec<EntityId> {
778        self.groups
779            .iter()
780            .flat_map(|g| g.lines().iter().map(LineInfo::entity))
781            .collect()
782    }
783
784    /// Number of lines in the simulation.
785    #[must_use]
786    pub fn line_count(&self) -> usize {
787        self.groups.iter().map(|g| g.lines().len()).sum()
788    }
789
790    /// Get all line entities in a group.
791    #[must_use]
792    pub fn lines_in_group(&self, group: GroupId) -> Vec<EntityId> {
793        self.groups
794            .iter()
795            .find(|g| g.id() == group)
796            .map_or_else(Vec::new, |g| {
797                g.lines().iter().map(LineInfo::entity).collect()
798            })
799    }
800
801    /// Get elevator entities on a specific line.
802    #[must_use]
803    pub fn elevators_on_line(&self, line: EntityId) -> Vec<EntityId> {
804        self.groups
805            .iter()
806            .flat_map(ElevatorGroup::lines)
807            .find(|li| li.entity() == line)
808            .map_or_else(Vec::new, |li| li.elevators().to_vec())
809    }
810
811    /// Get stop entities served by a specific line.
812    #[must_use]
813    pub fn stops_served_by_line(&self, line: EntityId) -> Vec<EntityId> {
814        self.groups
815            .iter()
816            .flat_map(ElevatorGroup::lines)
817            .find(|li| li.entity() == line)
818            .map_or_else(Vec::new, |li| li.serves().to_vec())
819    }
820
821    /// Find the stop at `position` that's served by `line`.
822    ///
823    /// Disambiguates the case where two stops on different lines share
824    /// the same physical position (e.g. parallel shafts at the same
825    /// floor, or a sky-lobby served by both a low and high bank). The
826    /// global [`World::find_stop_at_position`](crate::world::World::find_stop_at_position)
827    /// returns whichever stop wins the linear scan; this variant
828    /// scopes the lookup to the line's `serves` list so consumers
829    /// always get the stop *on the line they asked about*.
830    ///
831    /// Returns `None` if the line doesn't exist or no served stop
832    /// matches the position.
833    #[must_use]
834    pub fn find_stop_at_position_on_line(&self, position: f64, line: EntityId) -> Option<EntityId> {
835        let line_info = self
836            .groups
837            .iter()
838            .flat_map(ElevatorGroup::lines)
839            .find(|li| li.entity() == line)?;
840        self.world
841            .find_stop_at_position_in(position, line_info.serves())
842    }
843
844    /// Get the line entity for an elevator.
845    #[must_use]
846    pub fn line_for_elevator(&self, elevator: EntityId) -> Option<EntityId> {
847        self.groups
848            .iter()
849            .flat_map(ElevatorGroup::lines)
850            .find(|li| li.elevators().contains(&elevator))
851            .map(LineInfo::entity)
852    }
853
854    /// Iterate over elevators currently repositioning.
855    pub fn iter_repositioning_elevators(&self) -> impl Iterator<Item = EntityId> + '_ {
856        self.world
857            .iter_elevators()
858            .filter_map(|(id, _pos, car)| if car.repositioning() { Some(id) } else { None })
859    }
860
861    /// Get all line entities that serve a given stop.
862    #[must_use]
863    pub fn lines_serving_stop(&self, stop: EntityId) -> Vec<EntityId> {
864        self.groups
865            .iter()
866            .flat_map(ElevatorGroup::lines)
867            .filter(|li| li.serves().contains(&stop))
868            .map(LineInfo::entity)
869            .collect()
870    }
871
872    /// Get all group IDs that serve a given stop.
873    #[must_use]
874    pub fn groups_serving_stop(&self, stop: EntityId) -> Vec<GroupId> {
875        self.groups
876            .iter()
877            .filter(|g| g.stop_entities().contains(&stop))
878            .map(ElevatorGroup::id)
879            .collect()
880    }
881
882    // ── Topology queries ─────────────────────────────────────────────
883
884    /// Rebuild the topology graph if any mutation has invalidated it.
885    fn ensure_graph_built(&self) {
886        if let Ok(mut graph) = self.topo_graph.lock()
887            && graph.is_dirty()
888        {
889            graph.rebuild(&self.groups);
890        }
891    }
892
893    /// All stops reachable from a given stop through the line/group topology.
894    pub fn reachable_stops_from(&self, stop: EntityId) -> Vec<EntityId> {
895        self.ensure_graph_built();
896        self.topo_graph
897            .lock()
898            .map_or_else(|_| Vec::new(), |g| g.reachable_stops_from(stop))
899    }
900
901    /// Stops that serve as transfer points between groups.
902    pub fn transfer_points(&self) -> Vec<EntityId> {
903        self.ensure_graph_built();
904        TopologyGraph::transfer_points(&self.groups)
905    }
906
907    /// Find the shortest route between two stops, possibly spanning multiple groups.
908    pub fn shortest_route(&self, from: EntityId, to: EntityId) -> Option<Route> {
909        self.ensure_graph_built();
910        self.topo_graph
911            .lock()
912            .ok()
913            .and_then(|g| g.shortest_route(from, to))
914    }
915}