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