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