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 if !position.is_finite() {
902 return None;
903 }
904 let circumference = self.loop_circumference(line)?;
905 let stops = self.stops_served_by_line(line);
906 if stops.is_empty() {
907 return None;
908 }
909
910 let mut best: Option<(f64, EntityId)> = None;
911 for stop_eid in stops {
912 let Some(stop_pos) = self.world.stop_position(stop_eid) else {
913 continue;
914 };
915 let mut d =
916 crate::components::cyclic::forward_distance(position, stop_pos, circumference);
917 // Coincident → treat as a full lap ahead so we don't return
918 // the caller's current stop as "next".
919 if d <= 1e-9 {
920 d = circumference;
921 }
922 match best {
923 Some((d_best, _)) if d_best <= d => {}
924 _ => best = Some((d, stop_eid)),
925 }
926 }
927 best.map(|(_, eid)| eid)
928 }
929
930 /// Find the stop at `position` that's served by `line`.
931 ///
932 /// Disambiguates the case where two stops on different lines share
933 /// the same physical position (e.g. parallel shafts at the same
934 /// floor, or a sky-lobby served by both a low and high bank). The
935 /// global [`World::find_stop_at_position`](crate::world::World::find_stop_at_position)
936 /// returns whichever stop wins the linear scan; this variant
937 /// scopes the lookup to the line's `serves` list so consumers
938 /// always get the stop *on the line they asked about*.
939 ///
940 /// Returns `None` if the line doesn't exist or no served stop
941 /// matches the position.
942 #[must_use]
943 pub fn find_stop_at_position_on_line(&self, position: f64, line: EntityId) -> Option<EntityId> {
944 let line_info = self
945 .groups
946 .iter()
947 .flat_map(ElevatorGroup::lines)
948 .find(|li| li.entity() == line)?;
949 self.world
950 .find_stop_at_position_in(position, line_info.serves())
951 }
952
953 /// Get the line entity for an elevator.
954 #[must_use]
955 pub fn line_for_elevator(&self, elevator: EntityId) -> Option<EntityId> {
956 self.groups
957 .iter()
958 .flat_map(ElevatorGroup::lines)
959 .find(|li| li.elevators().contains(&elevator))
960 .map(LineInfo::entity)
961 }
962
963 /// Iterate over elevators currently repositioning.
964 pub fn iter_repositioning_elevators(&self) -> impl Iterator<Item = EntityId> + '_ {
965 self.world
966 .iter_elevators()
967 .filter_map(|(id, _pos, car)| if car.repositioning() { Some(id) } else { None })
968 }
969
970 /// Get all line entities that serve a given stop.
971 #[must_use]
972 pub fn lines_serving_stop(&self, stop: EntityId) -> Vec<EntityId> {
973 self.groups
974 .iter()
975 .flat_map(ElevatorGroup::lines)
976 .filter(|li| li.serves().contains(&stop))
977 .map(LineInfo::entity)
978 .collect()
979 }
980
981 /// Get all group IDs that serve a given stop.
982 #[must_use]
983 pub fn groups_serving_stop(&self, stop: EntityId) -> Vec<GroupId> {
984 self.groups
985 .iter()
986 .filter(|g| g.stop_entities().contains(&stop))
987 .map(ElevatorGroup::id)
988 .collect()
989 }
990
991 // ── Topology queries ─────────────────────────────────────────────
992
993 /// Rebuild the topology graph if any mutation has invalidated it.
994 fn ensure_graph_built(&self) {
995 if let Ok(mut graph) = self.topo_graph.lock()
996 && graph.is_dirty()
997 {
998 graph.rebuild(&self.groups);
999 }
1000 }
1001
1002 /// All stops reachable from a given stop through the line/group topology.
1003 pub fn reachable_stops_from(&self, stop: EntityId) -> Vec<EntityId> {
1004 self.ensure_graph_built();
1005 self.topo_graph
1006 .lock()
1007 .map_or_else(|_| Vec::new(), |g| g.reachable_stops_from(stop))
1008 }
1009
1010 /// Stops that serve as transfer points between groups.
1011 pub fn transfer_points(&self) -> Vec<EntityId> {
1012 self.ensure_graph_built();
1013 TopologyGraph::transfer_points(&self.groups)
1014 }
1015
1016 /// Find the shortest route between two stops, possibly spanning multiple groups.
1017 pub fn shortest_route(&self, from: EntityId, to: EntityId) -> Option<Route> {
1018 self.ensure_graph_built();
1019 self.topo_graph
1020 .lock()
1021 .ok()
1022 .and_then(|g| g.shortest_route(from, to))
1023 }
1024}