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;
11#[cfg(feature = "loop_lines")]
12use crate::entity::ElevatorId;
13use crate::entity::EntityId;
14use crate::error::SimError;
15use crate::events::Event;
16use crate::ids::GroupId;
17use crate::topology::TopologyGraph;
18
19use super::{ElevatorParams, LineParams, Simulation};
20
21/// Enforce `n_cars * min_headway <= circumference` for a Loop line.
22///
23/// `n_cars_after` is the post-operation car count (i.e. existing + 1
24/// when attaching a new car). `op` names the operation in the error
25/// message — e.g. `"attaching car"` or `"reassigning"` — so callers
26/// surface the right context. No-op for Linear lines.
27#[cfg(feature = "loop_lines")]
28fn check_loop_capacity(line: &Line, n_cars_after: usize, op: &'static str) -> Result<(), SimError> {
29 let LineKind::Loop {
30 circumference,
31 min_headway,
32 } = *line.kind()
33 else {
34 return Ok(());
35 };
36 #[allow(
37 clippy::cast_precision_loss,
38 reason = "n_cars_after is bounded by usize; the comparison is against a finite f64"
39 )]
40 let required = (n_cars_after as f64) * min_headway;
41 if required > circumference {
42 return Err(SimError::InvalidConfig {
43 field: "line.kind",
44 reason: format!(
45 "loop line: {op} would require {required} units of headway \
46 ({n_cars_after} cars × min_headway {min_headway}); \
47 exceeds circumference {circumference}",
48 ),
49 });
50 }
51 Ok(())
52}
53
54impl Simulation {
55 // ── Dynamic topology ────────────────────────────────────────────
56
57 /// Mark the topology graph dirty so it is rebuilt on next query.
58 fn mark_topo_dirty(&self) {
59 if let Ok(mut g) = self.topo_graph.lock() {
60 g.mark_dirty();
61 }
62 }
63
64 /// Find the (`group_index`, `line_index`) for a line entity.
65 fn find_line(&self, line: EntityId) -> Result<(usize, usize), SimError> {
66 self.groups
67 .iter()
68 .enumerate()
69 .find_map(|(gi, g)| {
70 g.lines()
71 .iter()
72 .position(|li| li.entity() == line)
73 .map(|li_idx| (gi, li_idx))
74 })
75 .ok_or(SimError::LineNotFound(line))
76 }
77
78 /// Add a new stop to a group at runtime. Returns its `EntityId`.
79 ///
80 /// Runtime-added stops have no `StopId` — they are identified purely
81 /// by `EntityId`. The `stop_lookup` (config `StopId` → `EntityId`)
82 /// is not updated.
83 ///
84 /// # Errors
85 ///
86 /// Returns [`SimError::LineNotFound`] if the line entity does not exist.
87 pub fn add_stop(
88 &mut self,
89 name: String,
90 position: f64,
91 line: EntityId,
92 ) -> Result<EntityId, SimError> {
93 if !position.is_finite() {
94 return Err(SimError::InvalidConfig {
95 field: "position",
96 reason: format!(
97 "stop position must be finite (got {position}); NaN/±inf \
98 corrupt SortedStops ordering and find_stop_at_position lookup"
99 ),
100 });
101 }
102
103 let group_id = self
104 .world
105 .line(line)
106 .map(|l| l.group)
107 .ok_or(SimError::LineNotFound(line))?;
108
109 let (group_idx, line_idx) = self.find_line(line)?;
110
111 let eid = self.world.spawn();
112 self.world.set_stop(eid, Stop { name, position });
113 self.world.set_position(eid, Position { value: position });
114
115 // Add to the line's serves list.
116 self.groups[group_idx].lines_mut()[line_idx].add_stop(eid);
117
118 // Add to the group's flat cache.
119 self.groups[group_idx].push_stop(eid);
120
121 // Maintain sorted-stops index for O(log n) PassingFloor detection.
122 if let Some(sorted) = self.world.resource_mut::<crate::world::SortedStops>() {
123 let idx = sorted.0.partition_point(|&(p, _)| p < position);
124 sorted.0.insert(idx, (position, eid));
125 }
126
127 self.mark_topo_dirty();
128 self.events.emit(Event::StopAdded {
129 stop: eid,
130 line,
131 group: group_id,
132 tick: self.tick,
133 });
134 Ok(eid)
135 }
136
137 /// Add a new elevator to a line at runtime. Returns its `EntityId`.
138 ///
139 /// # Errors
140 ///
141 /// Returns [`SimError::LineNotFound`] if the line entity does not exist.
142 pub fn add_elevator(
143 &mut self,
144 params: &ElevatorParams,
145 line: EntityId,
146 starting_position: f64,
147 ) -> Result<EntityId, SimError> {
148 // Reject malformed params before they reach the world. Without this,
149 // zero/negative physics or zero door ticks crash later phases.
150 super::construction::validate_elevator_physics(
151 params.max_speed.value(),
152 params.acceleration.value(),
153 params.deceleration.value(),
154 params.weight_capacity.value(),
155 params.inspection_speed_factor,
156 params.door_transition_ticks,
157 params.door_open_ticks,
158 params.bypass_load_up_pct,
159 params.bypass_load_down_pct,
160 )?;
161 if !starting_position.is_finite() {
162 return Err(SimError::InvalidConfig {
163 field: "starting_position",
164 reason: format!(
165 "must be finite (got {starting_position}); NaN/±inf corrupt \
166 SortedStops ordering and find_stop_at_position lookup"
167 ),
168 });
169 }
170
171 let group_id = self
172 .world
173 .line(line)
174 .map(|l| l.group)
175 .ok_or(SimError::LineNotFound(line))?;
176
177 let (group_idx, line_idx) = self.find_line(line)?;
178
179 // Enforce max_cars limit.
180 if let Some(max) = self.world.line(line).and_then(Line::max_cars) {
181 let current_count = self.groups[group_idx].lines()[line_idx].elevators().len();
182 if current_count >= max {
183 return Err(SimError::InvalidConfig {
184 field: "line.max_cars",
185 reason: format!("line already has {current_count} cars (max {max})"),
186 });
187 }
188 }
189
190 // Loop capacity guard: enforce `(n + 1) * min_headway <= circumference`
191 // at car-attach time. `add_line` deliberately skips this check when
192 // `max_cars = None`; this is where the deferred enforcement actually
193 // runs, so a Loop line without a `max_cars` cap cannot silently
194 // accept enough cars to violate the no-overtake invariant.
195 #[cfg(feature = "loop_lines")]
196 if let Some(line_ref) = self.world.line(line) {
197 let n_after = self.groups[group_idx].lines()[line_idx].elevators().len() + 1;
198 check_loop_capacity(line_ref, n_after, "attaching car")?;
199 }
200
201 let eid = self.world.spawn();
202 self.world.set_position(
203 eid,
204 Position {
205 value: starting_position,
206 },
207 );
208 self.world.set_velocity(eid, Velocity { value: 0.0 });
209 let is_loop = self.world.line(line).is_some_and(Line::is_loop);
210 self.world.set_elevator(
211 eid,
212 Elevator {
213 phase: ElevatorPhase::Idle,
214 door: DoorState::Closed,
215 max_speed: params.max_speed,
216 acceleration: params.acceleration,
217 deceleration: params.deceleration,
218 weight_capacity: params.weight_capacity,
219 current_load: crate::components::Weight::ZERO,
220 riders: Vec::new(),
221 target_stop: None,
222 door_transition_ticks: params.door_transition_ticks,
223 door_open_ticks: params.door_open_ticks,
224 line,
225 repositioning: false,
226 restricted_stops: params.restricted_stops.clone(),
227 inspection_speed_factor: params.inspection_speed_factor,
228 going_up: !is_loop,
229 going_down: !is_loop,
230 going_forward: is_loop,
231 move_count: 0,
232 door_command_queue: Vec::new(),
233 manual_target_velocity: None,
234 bypass_load_up_pct: params.bypass_load_up_pct,
235 bypass_load_down_pct: params.bypass_load_down_pct,
236 home_stop: None,
237 },
238 );
239 self.world
240 .set_destination_queue(eid, crate::components::DestinationQueue::new());
241 self.groups[group_idx].lines_mut()[line_idx].add_elevator(eid);
242 self.groups[group_idx].push_elevator(eid);
243
244 // Tag the elevator with its line's "line:{name}" tag.
245 let line_name = self.world.line(line).map(|l| l.name.clone());
246 if let Some(name) = line_name
247 && let Some(tags) = self
248 .world
249 .resource_mut::<crate::tagged_metrics::MetricTags>()
250 {
251 tags.tag(eid, format!("line:{name}"));
252 }
253
254 self.mark_topo_dirty();
255 self.events.emit(Event::ElevatorAdded {
256 elevator: eid,
257 line,
258 group: group_id,
259 tick: self.tick,
260 });
261 Ok(eid)
262 }
263
264 // ── Line / group topology ───────────────────────────────────────
265
266 /// Add a new line to a group. Returns the line entity.
267 ///
268 /// # Errors
269 ///
270 /// Returns [`SimError::GroupNotFound`] if the specified group does not exist.
271 /// Returns [`SimError::InvalidConfig`] for malformed bounds —
272 /// non-finite `min`/`max` or `min > max` on a `Linear` line, or
273 /// non-finite / non-positive `circumference` on a `Loop` line. For
274 /// `Loop` lines, also rejects `max_cars * min_headway > circumference`
275 /// — without enough room around the loop for every car at full
276 /// headway, the no-overtake invariant is unsatisfiable.
277 pub fn add_line(&mut self, params: &LineParams) -> Result<EntityId, SimError> {
278 // Resolve the requested kind; flat fields are the fallback only
279 // when no explicit kind was provided. Validation runs against
280 // the *resolved* kind so callers passing an explicit Loop don't
281 // get a spurious flat-field complaint.
282 let kind = params.kind.unwrap_or(LineKind::Linear {
283 min: params.min_position,
284 max: params.max_position,
285 });
286 kind.validate()
287 .map_err(|(field, reason)| SimError::InvalidConfig { field, reason })?;
288
289 // Loop-specific cross-field invariant — runtime mirror of the
290 // check in `validate_explicit_topology`.
291 //
292 // Asymmetric with the config-time path on `max_cars = None`:
293 // `validate_explicit_topology` falls back to `lc.elevators.len()`
294 // because the config-time line-config bundles its elevators, but
295 // a runtime-added line is always *empty* at this point — cars
296 // attach later via `add_elevator`. The gap is closed there:
297 // `add_elevator` re-evaluates `(n + 1) * min_headway <=
298 // circumference` before each attach, so a line without a
299 // `max_cars` cap still can't violate the no-overtake invariant.
300 #[cfg(feature = "loop_lines")]
301 if let LineKind::Loop {
302 circumference,
303 min_headway,
304 } = kind
305 && let Some(max_cars) = params.max_cars
306 && max_cars > 0
307 {
308 #[allow(
309 clippy::cast_precision_loss,
310 reason = "max_cars is bounded by usize; the comparison is against a finite f64"
311 )]
312 let required = (max_cars as f64) * min_headway;
313 if required > circumference {
314 return Err(SimError::InvalidConfig {
315 field: "line.kind",
316 reason: format!(
317 "loop line: {max_cars} cars × min_headway {min_headway} = {required} \
318 exceeds circumference {circumference}",
319 ),
320 });
321 }
322 }
323
324 let group_id = params.group;
325 let group = self
326 .groups
327 .iter_mut()
328 .find(|g| g.id() == group_id)
329 .ok_or(SimError::GroupNotFound(group_id))?;
330
331 let line_tag = format!("line:{}", params.name);
332
333 let eid = self.world.spawn();
334 self.world.set_line(
335 eid,
336 Line {
337 name: params.name.clone(),
338 group: group_id,
339 orientation: params.orientation,
340 position: params.position,
341 kind,
342 max_cars: params.max_cars,
343 },
344 );
345
346 group
347 .lines_mut()
348 .push(LineInfo::new(eid, Vec::new(), Vec::new()));
349
350 // Tag the line entity with "line:{name}" for per-line metrics.
351 if let Some(tags) = self
352 .world
353 .resource_mut::<crate::tagged_metrics::MetricTags>()
354 {
355 tags.tag(eid, line_tag);
356 }
357
358 self.mark_topo_dirty();
359 self.events.emit(Event::LineAdded {
360 line: eid,
361 group: group_id,
362 tick: self.tick,
363 });
364 Ok(eid)
365 }
366
367 /// Set the reachable position range of a line.
368 ///
369 /// Cars whose current position falls outside the new `[min, max]` are
370 /// clamped to the boundary. Phase is left untouched — a car mid-travel
371 /// keeps `MovingToStop` and the movement system reconciles on the
372 /// next tick.
373 ///
374 /// # Errors
375 ///
376 /// Returns [`SimError::LineNotFound`] if the line entity does not exist.
377 /// Returns [`SimError::InvalidConfig`] if `min` or `max` is non-finite
378 /// or `min > max`.
379 pub fn set_line_range(&mut self, line: EntityId, min: f64, max: f64) -> Result<(), SimError> {
380 if !min.is_finite() || !max.is_finite() {
381 return Err(SimError::InvalidConfig {
382 field: "line.range",
383 reason: format!("min/max must be finite (got min={min}, max={max})"),
384 });
385 }
386 if min > max {
387 return Err(SimError::InvalidConfig {
388 field: "line.range",
389 reason: format!("min ({min}) must be <= max ({max})"),
390 });
391 }
392 let line_ref = self
393 .world
394 .line_mut(line)
395 .ok_or(SimError::LineNotFound(line))?;
396 // `set_line_range` is a Linear-only operation; loops have no
397 // endpoints to set. Reject early so callers don't silently mutate
398 // the wrong field on a Loop line.
399 match &mut line_ref.kind {
400 LineKind::Linear {
401 min: kmin,
402 max: kmax,
403 } => {
404 *kmin = min;
405 *kmax = max;
406 }
407 #[cfg(feature = "loop_lines")]
408 LineKind::Loop { .. } => {
409 return Err(SimError::InvalidConfig {
410 field: "line.range",
411 reason: "set_line_range is not valid on a Loop line; \
412 change circumference via a future API instead"
413 .to_string(),
414 });
415 }
416 }
417
418 // Clamp any cars on this line whose position falls outside the new range.
419 let car_ids: Vec<EntityId> = self
420 .world
421 .iter_elevators()
422 .filter_map(|(eid, _, car)| (car.line == line).then_some(eid))
423 .collect();
424 for eid in car_ids {
425 // Skip cars without a Position component — clamping requires
426 // a real reading, and writing velocity alone (without a
427 // matching position update) would silently desync the two.
428 let Some(pos) = self.world.position(eid).map(|p| p.value) else {
429 continue;
430 };
431 if pos < min || pos > max {
432 let clamped = pos.clamp(min, max);
433 if let Some(p) = self.world.position_mut(eid) {
434 p.value = clamped;
435 }
436 if let Some(v) = self.world.velocity_mut(eid) {
437 v.value = 0.0;
438 }
439 }
440 }
441
442 self.mark_topo_dirty();
443 Ok(())
444 }
445
446 /// Remove a line and all its elevators from the simulation.
447 ///
448 /// Elevators on the line are disabled (not despawned) so riders are
449 /// properly ejected to the nearest stop.
450 ///
451 /// # Errors
452 ///
453 /// Returns [`SimError::LineNotFound`] if the line entity is not found
454 /// in any group.
455 pub fn remove_line(&mut self, line: EntityId) -> Result<(), SimError> {
456 let (group_idx, line_idx) = self.find_line(line)?;
457
458 let group_id = self.groups[group_idx].id();
459
460 // Collect elevator entities to disable.
461 let elevator_ids: Vec<EntityId> = self.groups[group_idx].lines()[line_idx]
462 .elevators()
463 .to_vec();
464
465 // Disable each elevator (ejects riders properly).
466 for eid in &elevator_ids {
467 // Ignore errors from already-disabled elevators.
468 let _ = self.disable(*eid);
469 }
470
471 // Remove the LineInfo from the group.
472 self.groups[group_idx].lines_mut().remove(line_idx);
473
474 // Rebuild flat caches.
475 self.groups[group_idx].rebuild_caches();
476
477 // Remove Line component from world.
478 self.world.remove_line(line);
479
480 // Drop any config-id → entity mapping pointing at the removed
481 // line. Mirrors the stop/elevator lookup cleanup so a
482 // subsequent `line_entity(id)` returns `None` rather than a
483 // dangling entity reference.
484 self.line_lookup.retain(|_, &mut eid| eid != line);
485
486 self.mark_topo_dirty();
487 self.events.emit(Event::LineRemoved {
488 line,
489 group: group_id,
490 tick: self.tick,
491 });
492 Ok(())
493 }
494
495 /// Remove an elevator from the simulation.
496 ///
497 /// The elevator is disabled first (ejecting any riders), then removed
498 /// from its line and despawned from the world.
499 ///
500 /// # Errors
501 ///
502 /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
503 pub fn remove_elevator(&mut self, elevator: EntityId) -> Result<(), SimError> {
504 let line = self
505 .world
506 .elevator(elevator)
507 .ok_or(SimError::EntityNotFound(elevator))?
508 .line();
509
510 // Disable first to eject riders and reset state.
511 let _ = self.disable(elevator);
512
513 // Find and remove from group/line topology. If `find_line` fails
514 // the elevator's `line` ref points at a removed/moved line — an
515 // inconsistent state, but we still want to despawn for cleanup.
516 //
517 // The `disable` call above already fired `notify_removed` on the
518 // group's dispatcher — the cache still includes the elevator at
519 // that point — so no additional notify is needed here. Custom
520 // `DispatchStrategy::notify_removed` impls that count invocations
521 // (e.g. tests with an `AtomicUsize`) can assume exactly one call
522 // per removal.
523 let resolved_group: Option<GroupId> = match self.find_line(line) {
524 Ok((group_idx, line_idx)) => {
525 self.groups[group_idx].lines_mut()[line_idx].remove_elevator(elevator);
526 self.groups[group_idx].rebuild_caches();
527 Some(self.groups[group_idx].id())
528 }
529 Err(_) => None,
530 };
531
532 // Only emit ElevatorRemoved when we resolved the actual group.
533 // Pre-fix this fired with `GroupId(0)` as a sentinel, masquerading
534 // a dangling-line cleanup as a legitimate group-0 removal (#266).
535 if let Some(group_id) = resolved_group {
536 self.events.emit(Event::ElevatorRemoved {
537 elevator,
538 line,
539 group: group_id,
540 tick: self.tick,
541 });
542 }
543
544 // Despawn from world.
545 self.world.despawn(elevator);
546
547 // Drop any config-id → entity mapping pointing at the removed
548 // elevator. Mirrors `remove_stop`'s `stop_lookup` cleanup so a
549 // subsequent `elevator_entity(id)` returns `None` rather than a
550 // dangling entity reference.
551 self.elevator_lookup.retain(|_, &mut eid| eid != elevator);
552
553 self.mark_topo_dirty();
554 Ok(())
555 }
556
557 /// Remove a stop from the simulation.
558 ///
559 /// The stop is disabled first (invalidating routes that reference it),
560 /// then removed from all lines and despawned from the world.
561 ///
562 /// # Errors
563 ///
564 /// Returns [`SimError::EntityNotFound`] if the stop does not exist.
565 pub fn remove_stop(&mut self, stop: EntityId) -> Result<(), SimError> {
566 if self.world.stop(stop).is_none() {
567 return Err(SimError::EntityNotFound(stop));
568 }
569
570 // Warn if resident riders exist at the stop before we disable it
571 // (disabling will abandon them, clearing the residents index).
572 let residents: Vec<EntityId> = self
573 .rider_index
574 .residents_at(stop)
575 .iter()
576 .copied()
577 .collect();
578 if !residents.is_empty() {
579 self.events
580 .emit(Event::ResidentsAtRemovedStop { stop, residents });
581 }
582
583 // Disable first to invalidate routes referencing this stop.
584 // Use the stop-specific helper so route-invalidation events
585 // carry `StopRemoved` rather than `StopDisabled`.
586 self.disable_stop_inner(stop, true);
587 self.world.disable(stop);
588 self.events.emit(Event::EntityDisabled {
589 entity: stop,
590 tick: self.tick,
591 });
592
593 // Scrub references to the removed stop from every elevator so the
594 // post-despawn tick loop does not chase a dead EntityId through
595 // `target_stop`, the destination queue, or access-control checks.
596 let elevator_ids: Vec<EntityId> =
597 self.world.iter_elevators().map(|(eid, _, _)| eid).collect();
598 for eid in elevator_ids {
599 if let Some(car) = self.world.elevator_mut(eid) {
600 if car.target_stop == Some(stop) {
601 car.target_stop = None;
602 }
603 car.restricted_stops.remove(&stop);
604 }
605 if let Some(q) = self.world.destination_queue_mut(eid) {
606 q.retain(|s| s != stop);
607 }
608 // Drop any car-call whose floor is the removed stop. Built-in
609 // strategies don't currently route on car_calls but the public
610 // `sim.car_calls(car)` accessor and custom strategies (via
611 // `car_calls_for`) would otherwise return dangling refs (#293).
612 if let Some(calls) = self.world.car_calls_mut(eid) {
613 calls.retain(|c| c.floor != stop);
614 }
615 }
616
617 // Remove from all lines and groups.
618 for group in &mut self.groups {
619 for line_info in group.lines_mut() {
620 line_info.remove_stop(stop);
621 }
622 group.rebuild_caches();
623 }
624
625 // Remove from SortedStops resource.
626 if let Some(sorted) = self.world.resource_mut::<crate::world::SortedStops>() {
627 sorted.0.retain(|&(_, s)| s != stop);
628 }
629
630 // Remove from stop_lookup.
631 self.stop_lookup.retain(|_, &mut eid| eid != stop);
632
633 self.events.emit(Event::StopRemoved {
634 stop,
635 tick: self.tick,
636 });
637
638 // Despawn from world.
639 self.world.despawn(stop);
640
641 // Rebuild the rider index to evict any stale per-stop entries
642 // pointing at the despawned stop. Cheap (O(riders)) and the only
643 // safe option once the stop EntityId is gone.
644 self.rider_index.rebuild(&self.world);
645
646 self.mark_topo_dirty();
647 Ok(())
648 }
649
650 /// Create a new dispatch group. Returns the group ID.
651 pub fn add_group(
652 &mut self,
653 name: impl Into<String>,
654 dispatch: impl DispatchStrategy + 'static,
655 ) -> GroupId {
656 let next_id = self
657 .groups
658 .iter()
659 .map(|g| g.id().0)
660 .max()
661 .map_or(0, |m| m + 1);
662 let group_id = GroupId(next_id);
663
664 self.groups
665 .push(ElevatorGroup::new(group_id, name.into(), Vec::new()));
666
667 self.dispatcher_set
668 .insert(group_id, Box::new(dispatch), BuiltinStrategy::Scan);
669 self.mark_topo_dirty();
670 group_id
671 }
672
673 /// Reassign a line to a different group. Returns the old `GroupId`.
674 ///
675 /// # Errors
676 ///
677 /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
678 /// Returns [`SimError::GroupNotFound`] if `new_group` does not exist.
679 pub fn assign_line_to_group(
680 &mut self,
681 line: EntityId,
682 new_group: GroupId,
683 ) -> Result<GroupId, SimError> {
684 let (old_group_idx, line_idx) = self.find_line(line)?;
685
686 // Verify new group exists.
687 if !self.groups.iter().any(|g| g.id() == new_group) {
688 return Err(SimError::GroupNotFound(new_group));
689 }
690
691 let old_group_id = self.groups[old_group_idx].id();
692
693 // Same-group reassign is a no-op. Skip BEFORE the notify_removed
694 // calls or we'd needlessly clear each elevator's dispatcher state
695 // (direction tracking in SCAN/LOOK, etc.) on a redundant move.
696 // Matches the early-return pattern in `reassign_elevator_to_line`.
697 if old_group_id == new_group {
698 return Ok(old_group_id);
699 }
700
701 // Enforce group homogeneity: a Loop line cannot land in a group
702 // with Linear members, and vice versa. The dispatch contract
703 // (e.g. `LoopSweep` / `LoopSchedule` strategies) assumes every
704 // line in their group is the same kind; mixing types silently
705 // breaks both ranking and the headway clamp because the group's
706 // strategy is single-typed.
707 #[cfg(feature = "loop_lines")]
708 if let Some(moved_is_loop) = self.world.line(line).map(Line::is_loop) {
709 let new_group_idx = self
710 .groups
711 .iter()
712 .position(|g| g.id() == new_group)
713 .ok_or(SimError::GroupNotFound(new_group))?;
714 let mismatch = self.groups[new_group_idx]
715 .lines()
716 .iter()
717 .filter_map(|li| self.world.line(li.entity()))
718 .any(|existing| existing.is_loop() != moved_is_loop);
719 if mismatch {
720 return Err(SimError::InvalidConfig {
721 field: "group.kind",
722 reason: format!(
723 "cannot mix Linear and Loop lines in the same group \
724 (moving line into group {new_group:?} would create a heterogeneous group)",
725 ),
726 });
727 }
728 }
729
730 // Notify the old dispatcher that these elevators are leaving — its
731 // per-elevator state (e.g. ScanDispatch.direction keyed by EntityId)
732 // would otherwise leak indefinitely as lines move between groups.
733 // Mirrors the cleanup `reassign_elevator_to_line` already does. (#257)
734 let elevators_to_notify: Vec<EntityId> = self.groups[old_group_idx].lines()[line_idx]
735 .elevators()
736 .to_vec();
737 if let Some(dispatcher) = self.dispatcher_set.strategies_mut().get_mut(&old_group_id) {
738 for eid in &elevators_to_notify {
739 dispatcher.notify_removed(*eid);
740 }
741 }
742
743 // Remove LineInfo from old group.
744 let line_info = self.groups[old_group_idx].lines_mut().remove(line_idx);
745 self.groups[old_group_idx].rebuild_caches();
746
747 // Re-lookup new_group_idx by ID — we didn't capture it before the
748 // mutation. (Removal of a `LineInfo` from a group's inner `lines`
749 // vec doesn't shift `self.groups` indices, so this is purely about
750 // not having stored the index earlier, not about index invalidation.)
751 let new_group_idx = self
752 .groups
753 .iter()
754 .position(|g| g.id() == new_group)
755 .ok_or(SimError::GroupNotFound(new_group))?;
756 self.groups[new_group_idx].lines_mut().push(line_info);
757 self.groups[new_group_idx].rebuild_caches();
758
759 // Update Line component's group field.
760 if let Some(line_comp) = self.world.line_mut(line) {
761 line_comp.group = new_group;
762 }
763
764 self.mark_topo_dirty();
765 self.events.emit(Event::LineReassigned {
766 line,
767 old_group: old_group_id,
768 new_group,
769 tick: self.tick,
770 });
771
772 Ok(old_group_id)
773 }
774
775 /// Reassign an elevator to a different line (swing-car pattern).
776 ///
777 /// The elevator is moved from its current line to the target line.
778 /// Both lines must be in the same group, or you must reassign the
779 /// line first via [`assign_line_to_group`](Self::assign_line_to_group).
780 ///
781 /// # Errors
782 ///
783 /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
784 /// Returns [`SimError::LineNotFound`] if the target line is not found in any group.
785 pub fn reassign_elevator_to_line(
786 &mut self,
787 elevator: EntityId,
788 new_line: EntityId,
789 ) -> Result<(), SimError> {
790 let old_line = self
791 .world
792 .elevator(elevator)
793 .ok_or(SimError::EntityNotFound(elevator))?
794 .line();
795
796 if old_line == new_line {
797 return Ok(());
798 }
799
800 // Validate both lines exist BEFORE mutating anything.
801 let (old_group_idx, old_line_idx) = self.find_line(old_line)?;
802 let (new_group_idx, new_line_idx) = self.find_line(new_line)?;
803
804 // Enforce max_cars on target line.
805 if let Some(max) = self.world.line(new_line).and_then(Line::max_cars) {
806 let current_count = self.groups[new_group_idx].lines()[new_line_idx]
807 .elevators()
808 .len();
809 if current_count >= max {
810 return Err(SimError::InvalidConfig {
811 field: "line.max_cars",
812 reason: format!("target line already has {current_count} cars (max {max})"),
813 });
814 }
815 }
816
817 // Loop capacity guard: same `(n + 1) * min_headway <= circumference`
818 // invariant `add_elevator` enforces. Without this, a swing
819 // re-assignment can push a Loop line over its headway capacity.
820 #[cfg(feature = "loop_lines")]
821 if let Some(line_ref) = self.world.line(new_line) {
822 let n_after = self.groups[new_group_idx].lines()[new_line_idx]
823 .elevators()
824 .len()
825 + 1;
826 check_loop_capacity(line_ref, n_after, "reassigning")?;
827 }
828
829 let old_group_id = self.groups[old_group_idx].id();
830 let new_group_id = self.groups[new_group_idx].id();
831
832 self.groups[old_group_idx].lines_mut()[old_line_idx].remove_elevator(elevator);
833 self.groups[new_group_idx].lines_mut()[new_line_idx].add_elevator(elevator);
834
835 if let Some(car) = self.world.elevator_mut(elevator) {
836 car.line = new_line;
837 }
838
839 self.groups[old_group_idx].rebuild_caches();
840 if new_group_idx != old_group_idx {
841 self.groups[new_group_idx].rebuild_caches();
842
843 // Notify the old group's dispatcher so it clears per-elevator
844 // state (ScanDispatch/LookDispatch track direction by
845 // EntityId). Matches the symmetry with `remove_elevator`.
846 if let Some(old_dispatcher) =
847 self.dispatcher_set.strategies_mut().get_mut(&old_group_id)
848 {
849 old_dispatcher.notify_removed(elevator);
850 }
851 }
852
853 self.mark_topo_dirty();
854
855 let _ = new_group_id; // reserved for symmetric notify_added once the trait gains one
856 self.events.emit(Event::ElevatorReassigned {
857 elevator,
858 old_line,
859 new_line,
860 tick: self.tick,
861 });
862
863 Ok(())
864 }
865
866 /// Add a stop to a line's served stops.
867 ///
868 /// # Errors
869 ///
870 /// Returns [`SimError::EntityNotFound`] if the stop does not exist.
871 /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
872 pub fn add_stop_to_line(&mut self, stop: EntityId, line: EntityId) -> Result<(), SimError> {
873 // Verify stop exists.
874 if self.world.stop(stop).is_none() {
875 return Err(SimError::EntityNotFound(stop));
876 }
877
878 let (group_idx, line_idx) = self.find_line(line)?;
879
880 let li = &mut self.groups[group_idx].lines_mut()[line_idx];
881 li.add_stop(stop);
882
883 self.groups[group_idx].push_stop(stop);
884
885 self.mark_topo_dirty();
886 Ok(())
887 }
888
889 /// Remove a stop from a line's served stops.
890 ///
891 /// # Errors
892 ///
893 /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
894 pub fn remove_stop_from_line(
895 &mut self,
896 stop: EntityId,
897 line: EntityId,
898 ) -> Result<(), SimError> {
899 let (group_idx, line_idx) = self.find_line(line)?;
900
901 self.groups[group_idx].lines_mut()[line_idx].remove_stop(stop);
902
903 // Rebuild group's stop_entities from all lines.
904 self.groups[group_idx].rebuild_caches();
905
906 self.mark_topo_dirty();
907 Ok(())
908 }
909
910 // ── Line / group queries ────────────────────────────────────────
911
912 /// Get all line entities across all groups.
913 #[must_use]
914 pub fn all_lines(&self) -> Vec<EntityId> {
915 self.groups
916 .iter()
917 .flat_map(|g| g.lines().iter().map(LineInfo::entity))
918 .collect()
919 }
920
921 /// Number of lines in the simulation.
922 #[must_use]
923 pub fn line_count(&self) -> usize {
924 self.groups.iter().map(|g| g.lines().len()).sum()
925 }
926
927 /// Get all line entities in a group.
928 #[must_use]
929 pub fn lines_in_group(&self, group: GroupId) -> Vec<EntityId> {
930 self.groups
931 .iter()
932 .find(|g| g.id() == group)
933 .map_or_else(Vec::new, |g| {
934 g.lines().iter().map(LineInfo::entity).collect()
935 })
936 }
937
938 /// Get elevator entities on a specific line.
939 #[must_use]
940 pub fn elevators_on_line(&self, line: EntityId) -> Vec<EntityId> {
941 self.groups
942 .iter()
943 .flat_map(ElevatorGroup::lines)
944 .find(|li| li.entity() == line)
945 .map_or_else(Vec::new, |li| li.elevators().to_vec())
946 }
947
948 /// Get stop entities served by a specific line.
949 #[must_use]
950 pub fn stops_served_by_line(&self, line: EntityId) -> Vec<EntityId> {
951 self.groups
952 .iter()
953 .flat_map(ElevatorGroup::lines)
954 .find(|li| li.entity() == line)
955 .map_or_else(Vec::new, |li| li.serves().to_vec())
956 }
957
958 /// Whether the given line has [`LineKind::Loop`] topology.
959 ///
960 /// Returns `false` for `Linear` lines, lines that don't exist, and
961 /// any future topology that isn't a closed loop. Hosts that wire
962 /// loop-aware rendering or dispatch should branch on this.
963 #[must_use]
964 pub fn is_loop(&self, line: EntityId) -> bool {
965 self.world
966 .line(line)
967 .is_some_and(crate::components::Line::is_loop)
968 }
969
970 /// Total path length of a [`LineKind::Loop`] line.
971 ///
972 /// Returns `None` for `Linear` lines and for missing line entities.
973 /// Hosts use this together with [`Self::is_loop`] to derive a
974 /// rendering radius (e.g. `r = C / (2π)`) for circular layouts.
975 #[must_use]
976 pub fn loop_circumference(&self, line: EntityId) -> Option<f64> {
977 self.world
978 .line(line)
979 .and_then(crate::components::Line::circumference)
980 }
981
982 /// On a [`LineKind::Loop`] line, the elevator that is immediately
983 /// *ahead* of `elevator` in forward cyclic order — i.e. its leader.
984 ///
985 /// Returns `None` for:
986 /// - `Linear` lines and missing entities
987 /// - solo cars on a Loop (no other car to lead them)
988 ///
989 /// A sibling car at the same physical position is treated as a valid
990 /// leader at `forward_distance = 0`; consumers that want a
991 /// strictly-ahead car should filter on `loop_forward_gap > 0`.
992 /// Useful for game-side UI (e.g. rendering coupled cars, "car ahead"
993 /// indicators, or train-style platoon visualisations).
994 #[cfg(feature = "loop_lines")]
995 #[must_use]
996 pub fn loop_leader(&self, elevator: ElevatorId) -> Option<ElevatorId> {
997 self.loop_leader_and_gap(elevator).map(|(id, _)| id)
998 }
999
1000 /// On a [`LineKind::Loop`] line, the forward cyclic gap from
1001 /// `elevator` to its [leader](Self::loop_leader).
1002 ///
1003 /// Returns `None` for Linear lines, missing entities, and solo cars.
1004 /// Always returns a value in `[0, circumference)` when `Some`;
1005 /// callers can compare against the line's `min_headway` to detect a
1006 /// car pressed up against the headway clamp (and therefore unable to
1007 /// advance regardless of throttle).
1008 #[cfg(feature = "loop_lines")]
1009 #[must_use]
1010 pub fn loop_forward_gap(&self, elevator: ElevatorId) -> Option<f64> {
1011 self.loop_leader_and_gap(elevator).map(|(_, gap)| gap)
1012 }
1013
1014 /// Joint helper for `loop_leader` and `loop_forward_gap`: a single
1015 /// `iter_elevators` walk yields both the leader's identity and the
1016 /// forward-cyclic distance to it.
1017 #[cfg(feature = "loop_lines")]
1018 fn loop_leader_and_gap(&self, elevator: ElevatorId) -> Option<(ElevatorId, f64)> {
1019 let eid = elevator.entity();
1020 let car = self.world.elevator(eid)?;
1021 let line = car.line;
1022 let circumference = self.loop_circumference(line)?;
1023 let pos = self.world.position(eid)?.value;
1024 self.world
1025 .iter_elevators()
1026 .filter(|&(other, _, other_car)| other != eid && other_car.line == line)
1027 .map(|(other, p, _)| {
1028 (
1029 crate::components::cyclic::forward_distance(pos, p.value, circumference),
1030 other,
1031 )
1032 })
1033 .min_by(|a, b| a.0.total_cmp(&b.0))
1034 .map(|(gap, e)| (ElevatorId::from(e), gap))
1035 }
1036
1037 /// On a [`LineKind::Loop`] line, the stop that comes immediately
1038 /// *after* `position` in forward cyclic order.
1039 ///
1040 /// Walks the line's served stops, computes the forward cyclic
1041 /// distance from `position` to each, and returns the one with the
1042 /// smallest non-zero distance. A stop coincident with `position`
1043 /// is treated as a "full lap ahead" — the caller already *is* at
1044 /// that stop, so the next forward stop is what they want.
1045 ///
1046 /// Returns `None` if the line is not a Loop, the line entity is
1047 /// unknown, the line serves no stops, or `position` is non-finite.
1048 /// Non-finite `position` is rejected up front because
1049 /// [`forward_distance`](crate::components::cyclic::forward_distance)
1050 /// is documented to return `0.0` on non-finite inputs — without the
1051 /// guard, every served stop would tie at `d = circumference` and
1052 /// the first one in the list would be returned as a valid-looking
1053 /// `EntityId` despite the input being meaningless.
1054 #[must_use]
1055 pub fn loop_next_stop(&self, line: EntityId, position: f64) -> Option<EntityId> {
1056 let circumference = self.loop_circumference(line)?;
1057 let stops = self.stops_served_by_line(line);
1058 crate::dispatch::loop_next_stop_forward(&self.world, circumference, &stops, position)
1059 }
1060
1061 /// Find the stop at `position` that's served by `line`.
1062 ///
1063 /// Disambiguates the case where two stops on different lines share
1064 /// the same physical position (e.g. parallel shafts at the same
1065 /// floor, or a sky-lobby served by both a low and high bank). The
1066 /// global [`World::find_stop_at_position`](crate::world::World::find_stop_at_position)
1067 /// returns whichever stop wins the linear scan; this variant
1068 /// scopes the lookup to the line's `serves` list so consumers
1069 /// always get the stop *on the line they asked about*.
1070 ///
1071 /// Returns `None` if the line doesn't exist or no served stop
1072 /// matches the position.
1073 #[must_use]
1074 pub fn find_stop_at_position_on_line(&self, position: f64, line: EntityId) -> Option<EntityId> {
1075 let line_info = self
1076 .groups
1077 .iter()
1078 .flat_map(ElevatorGroup::lines)
1079 .find(|li| li.entity() == line)?;
1080 self.world
1081 .find_stop_at_position_in(position, line_info.serves())
1082 }
1083
1084 /// Get the line entity for an elevator.
1085 #[must_use]
1086 pub fn line_for_elevator(&self, elevator: EntityId) -> Option<EntityId> {
1087 self.groups
1088 .iter()
1089 .flat_map(ElevatorGroup::lines)
1090 .find(|li| li.elevators().contains(&elevator))
1091 .map(LineInfo::entity)
1092 }
1093
1094 /// Iterate over elevators currently repositioning.
1095 pub fn iter_repositioning_elevators(&self) -> impl Iterator<Item = EntityId> + '_ {
1096 self.world
1097 .iter_elevators()
1098 .filter_map(|(id, _pos, car)| if car.repositioning() { Some(id) } else { None })
1099 }
1100
1101 /// Get all line entities that serve a given stop.
1102 #[must_use]
1103 pub fn lines_serving_stop(&self, stop: EntityId) -> Vec<EntityId> {
1104 self.groups
1105 .iter()
1106 .flat_map(ElevatorGroup::lines)
1107 .filter(|li| li.serves().contains(&stop))
1108 .map(LineInfo::entity)
1109 .collect()
1110 }
1111
1112 /// Get all group IDs that serve a given stop.
1113 #[must_use]
1114 pub fn groups_serving_stop(&self, stop: EntityId) -> Vec<GroupId> {
1115 self.groups
1116 .iter()
1117 .filter(|g| g.stop_entities().contains(&stop))
1118 .map(ElevatorGroup::id)
1119 .collect()
1120 }
1121
1122 // ── Topology queries ─────────────────────────────────────────────
1123
1124 /// Rebuild the topology graph if any mutation has invalidated it.
1125 fn ensure_graph_built(&self) {
1126 if let Ok(mut graph) = self.topo_graph.lock()
1127 && graph.is_dirty()
1128 {
1129 graph.rebuild(&self.groups);
1130 }
1131 }
1132
1133 /// All stops reachable from a given stop through the line/group topology.
1134 pub fn reachable_stops_from(&self, stop: EntityId) -> Vec<EntityId> {
1135 self.ensure_graph_built();
1136 self.topo_graph
1137 .lock()
1138 .map_or_else(|_| Vec::new(), |g| g.reachable_stops_from(stop))
1139 }
1140
1141 /// Stops that serve as transfer points between groups.
1142 pub fn transfer_points(&self) -> Vec<EntityId> {
1143 self.ensure_graph_built();
1144 TopologyGraph::transfer_points(&self.groups)
1145 }
1146
1147 /// Find the shortest route between two stops, possibly spanning multiple groups.
1148 pub fn shortest_route(&self, from: EntityId, to: EntityId) -> Option<Route> {
1149 self.ensure_graph_built();
1150 self.topo_graph
1151 .lock()
1152 .ok()
1153 .and_then(|g| g.shortest_route(from, to))
1154 }
1155}