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            },
191        );
192        self.world
193            .set_destination_queue(eid, crate::components::DestinationQueue::new());
194        self.groups[group_idx].lines_mut()[line_idx]
195            .elevators_mut()
196            .push(eid);
197        self.groups[group_idx].push_elevator(eid);
198
199        // Tag the elevator with its line's "line:{name}" tag.
200        let line_name = self.world.line(line).map(|l| l.name.clone());
201        if let Some(name) = line_name
202            && let Some(tags) = self
203                .world
204                .resource_mut::<crate::tagged_metrics::MetricTags>()
205        {
206            tags.tag(eid, format!("line:{name}"));
207        }
208
209        self.mark_topo_dirty();
210        self.events.emit(Event::ElevatorAdded {
211            elevator: eid,
212            line,
213            group: group_id,
214            tick: self.tick,
215        });
216        Ok(eid)
217    }
218
219    // ── Line / group topology ───────────────────────────────────────
220
221    /// Add a new line to a group. Returns the line entity.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`SimError::GroupNotFound`] if the specified group does not exist.
226    pub fn add_line(&mut self, params: &LineParams) -> Result<EntityId, SimError> {
227        let group_id = params.group;
228        let group = self
229            .groups
230            .iter_mut()
231            .find(|g| g.id() == group_id)
232            .ok_or(SimError::GroupNotFound(group_id))?;
233
234        let line_tag = format!("line:{}", params.name);
235
236        let eid = self.world.spawn();
237        self.world.set_line(
238            eid,
239            Line {
240                name: params.name.clone(),
241                group: group_id,
242                orientation: params.orientation,
243                position: params.position,
244                min_position: params.min_position,
245                max_position: params.max_position,
246                max_cars: params.max_cars,
247            },
248        );
249
250        group
251            .lines_mut()
252            .push(LineInfo::new(eid, Vec::new(), Vec::new()));
253
254        // Tag the line entity with "line:{name}" for per-line metrics.
255        if let Some(tags) = self
256            .world
257            .resource_mut::<crate::tagged_metrics::MetricTags>()
258        {
259            tags.tag(eid, line_tag);
260        }
261
262        self.mark_topo_dirty();
263        self.events.emit(Event::LineAdded {
264            line: eid,
265            group: group_id,
266            tick: self.tick,
267        });
268        Ok(eid)
269    }
270
271    /// Remove a line and all its elevators from the simulation.
272    ///
273    /// Elevators on the line are disabled (not despawned) so riders are
274    /// properly ejected to the nearest stop.
275    ///
276    /// # Errors
277    ///
278    /// Returns [`SimError::LineNotFound`] if the line entity is not found
279    /// in any group.
280    pub fn remove_line(&mut self, line: EntityId) -> Result<(), SimError> {
281        let (group_idx, line_idx) = self.find_line(line)?;
282
283        let group_id = self.groups[group_idx].id();
284
285        // Collect elevator entities to disable.
286        let elevator_ids: Vec<EntityId> = self.groups[group_idx].lines()[line_idx]
287            .elevators()
288            .to_vec();
289
290        // Disable each elevator (ejects riders properly).
291        for eid in &elevator_ids {
292            // Ignore errors from already-disabled elevators.
293            let _ = self.disable(*eid);
294        }
295
296        // Remove the LineInfo from the group.
297        self.groups[group_idx].lines_mut().remove(line_idx);
298
299        // Rebuild flat caches.
300        self.groups[group_idx].rebuild_caches();
301
302        // Remove Line component from world.
303        self.world.remove_line(line);
304
305        self.mark_topo_dirty();
306        self.events.emit(Event::LineRemoved {
307            line,
308            group: group_id,
309            tick: self.tick,
310        });
311        Ok(())
312    }
313
314    /// Remove an elevator from the simulation.
315    ///
316    /// The elevator is disabled first (ejecting any riders), then removed
317    /// from its line and despawned from the world.
318    ///
319    /// # Errors
320    ///
321    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
322    pub fn remove_elevator(&mut self, elevator: EntityId) -> Result<(), SimError> {
323        let line = self
324            .world
325            .elevator(elevator)
326            .ok_or(SimError::EntityNotFound(elevator))?
327            .line();
328
329        // Disable first to eject riders and reset state.
330        let _ = self.disable(elevator);
331
332        // Find and remove from group/line topology. If `find_line` fails
333        // the elevator's `line` ref points at a removed/moved line — an
334        // inconsistent state, but we still want to despawn for cleanup.
335        //
336        // The `disable` call above already fired `notify_removed` on the
337        // group's dispatcher — the cache still includes the elevator at
338        // that point — so no additional notify is needed here. Custom
339        // `DispatchStrategy::notify_removed` impls that count invocations
340        // (e.g. tests with an `AtomicUsize`) can assume exactly one call
341        // per removal.
342        let resolved_group: Option<GroupId> = match self.find_line(line) {
343            Ok((group_idx, line_idx)) => {
344                self.groups[group_idx].lines_mut()[line_idx]
345                    .elevators_mut()
346                    .retain(|&e| e != elevator);
347                self.groups[group_idx].rebuild_caches();
348                Some(self.groups[group_idx].id())
349            }
350            Err(_) => None,
351        };
352
353        // Only emit ElevatorRemoved when we resolved the actual group.
354        // Pre-fix this fired with `GroupId(0)` as a sentinel, masquerading
355        // a dangling-line cleanup as a legitimate group-0 removal (#266).
356        if let Some(group_id) = resolved_group {
357            self.events.emit(Event::ElevatorRemoved {
358                elevator,
359                line,
360                group: group_id,
361                tick: self.tick,
362            });
363        }
364
365        // Despawn from world.
366        self.world.despawn(elevator);
367
368        self.mark_topo_dirty();
369        Ok(())
370    }
371
372    /// Remove a stop from the simulation.
373    ///
374    /// The stop is disabled first (invalidating routes that reference it),
375    /// then removed from all lines and despawned from the world.
376    ///
377    /// # Errors
378    ///
379    /// Returns [`SimError::EntityNotFound`] if the stop does not exist.
380    pub fn remove_stop(&mut self, stop: EntityId) -> Result<(), SimError> {
381        if self.world.stop(stop).is_none() {
382            return Err(SimError::EntityNotFound(stop));
383        }
384
385        // Warn if resident riders exist at the stop before we disable it
386        // (disabling will abandon them, clearing the residents index).
387        let residents: Vec<EntityId> = self
388            .rider_index
389            .residents_at(stop)
390            .iter()
391            .copied()
392            .collect();
393        if !residents.is_empty() {
394            self.events
395                .emit(Event::ResidentsAtRemovedStop { stop, residents });
396        }
397
398        // Disable first to invalidate routes referencing this stop.
399        // Use the stop-specific helper so route-invalidation events
400        // carry `StopRemoved` rather than `StopDisabled`.
401        self.disable_stop_inner(stop, true);
402        self.world.disable(stop);
403        self.events.emit(Event::EntityDisabled {
404            entity: stop,
405            tick: self.tick,
406        });
407
408        // Scrub references to the removed stop from every elevator so the
409        // post-despawn tick loop does not chase a dead EntityId through
410        // `target_stop`, the destination queue, or access-control checks.
411        let elevator_ids: Vec<EntityId> =
412            self.world.iter_elevators().map(|(eid, _, _)| eid).collect();
413        for eid in elevator_ids {
414            if let Some(car) = self.world.elevator_mut(eid) {
415                if car.target_stop == Some(stop) {
416                    car.target_stop = None;
417                }
418                car.restricted_stops.remove(&stop);
419            }
420            if let Some(q) = self.world.destination_queue_mut(eid) {
421                q.retain(|s| s != stop);
422            }
423            // Drop any car-call whose floor is the removed stop. Built-in
424            // strategies don't currently route on car_calls but the public
425            // `sim.car_calls(car)` accessor and custom strategies (via
426            // `car_calls_for`) would otherwise return dangling refs (#293).
427            if let Some(calls) = self.world.car_calls_mut(eid) {
428                calls.retain(|c| c.floor != stop);
429            }
430        }
431
432        // Remove from all lines and groups.
433        for group in &mut self.groups {
434            for line_info in group.lines_mut() {
435                line_info.serves_mut().retain(|&s| s != stop);
436            }
437            group.rebuild_caches();
438        }
439
440        // Remove from SortedStops resource.
441        if let Some(sorted) = self.world.resource_mut::<crate::world::SortedStops>() {
442            sorted.0.retain(|&(_, s)| s != stop);
443        }
444
445        // Remove from stop_lookup.
446        self.stop_lookup.retain(|_, &mut eid| eid != stop);
447
448        self.events.emit(Event::StopRemoved {
449            stop,
450            tick: self.tick,
451        });
452
453        // Despawn from world.
454        self.world.despawn(stop);
455
456        // Rebuild the rider index to evict any stale per-stop entries
457        // pointing at the despawned stop. Cheap (O(riders)) and the only
458        // safe option once the stop EntityId is gone.
459        self.rider_index.rebuild(&self.world);
460
461        self.mark_topo_dirty();
462        Ok(())
463    }
464
465    /// Create a new dispatch group. Returns the group ID.
466    pub fn add_group(
467        &mut self,
468        name: impl Into<String>,
469        dispatch: impl DispatchStrategy + 'static,
470    ) -> GroupId {
471        let next_id = self
472            .groups
473            .iter()
474            .map(|g| g.id().0)
475            .max()
476            .map_or(0, |m| m + 1);
477        let group_id = GroupId(next_id);
478
479        self.groups
480            .push(ElevatorGroup::new(group_id, name.into(), Vec::new()));
481
482        self.dispatchers.insert(group_id, Box::new(dispatch));
483        self.strategy_ids.insert(group_id, BuiltinStrategy::Scan);
484        self.mark_topo_dirty();
485        group_id
486    }
487
488    /// Reassign a line to a different group. Returns the old `GroupId`.
489    ///
490    /// # Errors
491    ///
492    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
493    /// Returns [`SimError::GroupNotFound`] if `new_group` does not exist.
494    pub fn assign_line_to_group(
495        &mut self,
496        line: EntityId,
497        new_group: GroupId,
498    ) -> Result<GroupId, SimError> {
499        let (old_group_idx, line_idx) = self.find_line(line)?;
500
501        // Verify new group exists.
502        if !self.groups.iter().any(|g| g.id() == new_group) {
503            return Err(SimError::GroupNotFound(new_group));
504        }
505
506        let old_group_id = self.groups[old_group_idx].id();
507
508        // Same-group reassign is a no-op. Skip BEFORE the notify_removed
509        // calls or we'd needlessly clear each elevator's dispatcher state
510        // (direction tracking in SCAN/LOOK, etc.) on a redundant move.
511        // Matches the early-return pattern in `reassign_elevator_to_line`.
512        if old_group_id == new_group {
513            return Ok(old_group_id);
514        }
515
516        // Notify the old dispatcher that these elevators are leaving — its
517        // per-elevator state (e.g. ScanDispatch.direction keyed by EntityId)
518        // would otherwise leak indefinitely as lines move between groups.
519        // Mirrors the cleanup `reassign_elevator_to_line` already does. (#257)
520        let elevators_to_notify: Vec<EntityId> = self.groups[old_group_idx].lines()[line_idx]
521            .elevators()
522            .to_vec();
523        if let Some(dispatcher) = self.dispatchers.get_mut(&old_group_id) {
524            for eid in &elevators_to_notify {
525                dispatcher.notify_removed(*eid);
526            }
527        }
528
529        // Remove LineInfo from old group.
530        let line_info = self.groups[old_group_idx].lines_mut().remove(line_idx);
531        self.groups[old_group_idx].rebuild_caches();
532
533        // Re-lookup new_group_idx by ID — we didn't capture it before the
534        // mutation. (Removal of a `LineInfo` from a group's inner `lines`
535        // vec doesn't shift `self.groups` indices, so this is purely about
536        // not having stored the index earlier, not about index invalidation.)
537        let new_group_idx = self
538            .groups
539            .iter()
540            .position(|g| g.id() == new_group)
541            .ok_or(SimError::GroupNotFound(new_group))?;
542        self.groups[new_group_idx].lines_mut().push(line_info);
543        self.groups[new_group_idx].rebuild_caches();
544
545        // Update Line component's group field.
546        if let Some(line_comp) = self.world.line_mut(line) {
547            line_comp.group = new_group;
548        }
549
550        self.mark_topo_dirty();
551        self.events.emit(Event::LineReassigned {
552            line,
553            old_group: old_group_id,
554            new_group,
555            tick: self.tick,
556        });
557
558        Ok(old_group_id)
559    }
560
561    /// Reassign an elevator to a different line (swing-car pattern).
562    ///
563    /// The elevator is moved from its current line to the target line.
564    /// Both lines must be in the same group, or you must reassign the
565    /// line first via [`assign_line_to_group`](Self::assign_line_to_group).
566    ///
567    /// # Errors
568    ///
569    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
570    /// Returns [`SimError::LineNotFound`] if the target line is not found in any group.
571    pub fn reassign_elevator_to_line(
572        &mut self,
573        elevator: EntityId,
574        new_line: EntityId,
575    ) -> Result<(), SimError> {
576        let old_line = self
577            .world
578            .elevator(elevator)
579            .ok_or(SimError::EntityNotFound(elevator))?
580            .line();
581
582        if old_line == new_line {
583            return Ok(());
584        }
585
586        // Validate both lines exist BEFORE mutating anything.
587        let (old_group_idx, old_line_idx) = self.find_line(old_line)?;
588        let (new_group_idx, new_line_idx) = self.find_line(new_line)?;
589
590        // Enforce max_cars on target line.
591        if let Some(max) = self.world.line(new_line).and_then(Line::max_cars) {
592            let current_count = self.groups[new_group_idx].lines()[new_line_idx]
593                .elevators()
594                .len();
595            if current_count >= max {
596                return Err(SimError::InvalidConfig {
597                    field: "line.max_cars",
598                    reason: format!("target line already has {current_count} cars (max {max})"),
599                });
600            }
601        }
602
603        let old_group_id = self.groups[old_group_idx].id();
604        let new_group_id = self.groups[new_group_idx].id();
605
606        self.groups[old_group_idx].lines_mut()[old_line_idx]
607            .elevators_mut()
608            .retain(|&e| e != elevator);
609        self.groups[new_group_idx].lines_mut()[new_line_idx]
610            .elevators_mut()
611            .push(elevator);
612
613        if let Some(car) = self.world.elevator_mut(elevator) {
614            car.line = new_line;
615        }
616
617        self.groups[old_group_idx].rebuild_caches();
618        if new_group_idx != old_group_idx {
619            self.groups[new_group_idx].rebuild_caches();
620
621            // Notify the old group's dispatcher so it clears per-elevator
622            // state (ScanDispatch/LookDispatch track direction by
623            // EntityId). Matches the symmetry with `remove_elevator`.
624            if let Some(old_dispatcher) = self.dispatchers.get_mut(&old_group_id) {
625                old_dispatcher.notify_removed(elevator);
626            }
627        }
628
629        self.mark_topo_dirty();
630
631        let _ = new_group_id; // reserved for symmetric notify_added once the trait gains one
632        self.events.emit(Event::ElevatorReassigned {
633            elevator,
634            old_line,
635            new_line,
636            tick: self.tick,
637        });
638
639        Ok(())
640    }
641
642    /// Add a stop to a line's served stops.
643    ///
644    /// # Errors
645    ///
646    /// Returns [`SimError::EntityNotFound`] if the stop does not exist.
647    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
648    pub fn add_stop_to_line(&mut self, stop: EntityId, line: EntityId) -> Result<(), SimError> {
649        // Verify stop exists.
650        if self.world.stop(stop).is_none() {
651            return Err(SimError::EntityNotFound(stop));
652        }
653
654        let (group_idx, line_idx) = self.find_line(line)?;
655
656        let li = &mut self.groups[group_idx].lines_mut()[line_idx];
657        if !li.serves().contains(&stop) {
658            li.serves_mut().push(stop);
659        }
660
661        self.groups[group_idx].push_stop(stop);
662
663        self.mark_topo_dirty();
664        Ok(())
665    }
666
667    /// Remove a stop from a line's served stops.
668    ///
669    /// # Errors
670    ///
671    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
672    pub fn remove_stop_from_line(
673        &mut self,
674        stop: EntityId,
675        line: EntityId,
676    ) -> Result<(), SimError> {
677        let (group_idx, line_idx) = self.find_line(line)?;
678
679        self.groups[group_idx].lines_mut()[line_idx]
680            .serves_mut()
681            .retain(|&s| s != stop);
682
683        // Rebuild group's stop_entities from all lines.
684        self.groups[group_idx].rebuild_caches();
685
686        self.mark_topo_dirty();
687        Ok(())
688    }
689
690    // ── Line / group queries ────────────────────────────────────────
691
692    /// Get all line entities across all groups.
693    #[must_use]
694    pub fn all_lines(&self) -> Vec<EntityId> {
695        self.groups
696            .iter()
697            .flat_map(|g| g.lines().iter().map(LineInfo::entity))
698            .collect()
699    }
700
701    /// Number of lines in the simulation.
702    #[must_use]
703    pub fn line_count(&self) -> usize {
704        self.groups.iter().map(|g| g.lines().len()).sum()
705    }
706
707    /// Get all line entities in a group.
708    #[must_use]
709    pub fn lines_in_group(&self, group: GroupId) -> Vec<EntityId> {
710        self.groups
711            .iter()
712            .find(|g| g.id() == group)
713            .map_or_else(Vec::new, |g| {
714                g.lines().iter().map(LineInfo::entity).collect()
715            })
716    }
717
718    /// Get elevator entities on a specific line.
719    #[must_use]
720    pub fn elevators_on_line(&self, line: EntityId) -> Vec<EntityId> {
721        self.groups
722            .iter()
723            .flat_map(ElevatorGroup::lines)
724            .find(|li| li.entity() == line)
725            .map_or_else(Vec::new, |li| li.elevators().to_vec())
726    }
727
728    /// Get stop entities served by a specific line.
729    #[must_use]
730    pub fn stops_served_by_line(&self, line: EntityId) -> Vec<EntityId> {
731        self.groups
732            .iter()
733            .flat_map(ElevatorGroup::lines)
734            .find(|li| li.entity() == line)
735            .map_or_else(Vec::new, |li| li.serves().to_vec())
736    }
737
738    /// Get the line entity for an elevator.
739    #[must_use]
740    pub fn line_for_elevator(&self, elevator: EntityId) -> Option<EntityId> {
741        self.groups
742            .iter()
743            .flat_map(ElevatorGroup::lines)
744            .find(|li| li.elevators().contains(&elevator))
745            .map(LineInfo::entity)
746    }
747
748    /// Iterate over elevators currently repositioning.
749    pub fn iter_repositioning_elevators(&self) -> impl Iterator<Item = EntityId> + '_ {
750        self.world
751            .iter_elevators()
752            .filter_map(|(id, _pos, car)| if car.repositioning() { Some(id) } else { None })
753    }
754
755    /// Get all line entities that serve a given stop.
756    #[must_use]
757    pub fn lines_serving_stop(&self, stop: EntityId) -> Vec<EntityId> {
758        self.groups
759            .iter()
760            .flat_map(ElevatorGroup::lines)
761            .filter(|li| li.serves().contains(&stop))
762            .map(LineInfo::entity)
763            .collect()
764    }
765
766    /// Get all group IDs that serve a given stop.
767    #[must_use]
768    pub fn groups_serving_stop(&self, stop: EntityId) -> Vec<GroupId> {
769        self.groups
770            .iter()
771            .filter(|g| g.stop_entities().contains(&stop))
772            .map(ElevatorGroup::id)
773            .collect()
774    }
775
776    // ── Topology queries ─────────────────────────────────────────────
777
778    /// Rebuild the topology graph if any mutation has invalidated it.
779    fn ensure_graph_built(&self) {
780        if let Ok(mut graph) = self.topo_graph.lock()
781            && graph.is_dirty()
782        {
783            graph.rebuild(&self.groups);
784        }
785    }
786
787    /// All stops reachable from a given stop through the line/group topology.
788    pub fn reachable_stops_from(&self, stop: EntityId) -> Vec<EntityId> {
789        self.ensure_graph_built();
790        self.topo_graph
791            .lock()
792            .map_or_else(|_| Vec::new(), |g| g.reachable_stops_from(stop))
793    }
794
795    /// Stops that serve as transfer points between groups.
796    pub fn transfer_points(&self) -> Vec<EntityId> {
797        self.ensure_graph_built();
798        TopologyGraph::transfer_points(&self.groups)
799    }
800
801    /// Find the shortest route between two stops, possibly spanning multiple groups.
802    pub fn shortest_route(&self, from: EntityId, to: EntityId) -> Option<Route> {
803        self.ensure_graph_built();
804        self.topo_graph
805            .lock()
806            .ok()
807            .and_then(|g| g.shortest_route(from, to))
808    }
809}