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