elevator_core/dispatch/destination.rs
1//! Hall-call destination dispatch ("DCS").
2//!
3//! Destination dispatch assigns each rider to a specific car at hall-call
4//! time (when their destination is first known) and the assignment is
5//! **sticky** — it never changes for the rider's lifetime, and no other car
6//! will pick them up. The controller minimizes each rider's own travel time,
7//! using a simple cost model:
8//!
9//! ```text
10//! J(C) = pickup_time(C, origin)
11//! + ride_time(origin, dest)
12//! + stop_penalty * new_stops_added(C, origin, dest)
13//! ```
14//!
15//! Assignments are recorded as an [`AssignedCar`] extension component on the
16//! rider; the loading filter in `crate::systems::loading` consults this to
17//! enforce the stickiness invariant.
18//!
19//! This is a sim — not a faithful reproduction of any vendor's controller.
20//! Each assigned car's [`DestinationQueue`](crate::components::DestinationQueue)
21//! is rebuilt every dispatch tick from the set of live sticky commitments
22//! (waiting riders contribute origin + dest; riding riders contribute dest)
23//! and arranged into a direction-aware two-run (plus fallback third-run)
24//! monotone sequence so the car visits stops in sweep order rather than
25//! in the order assignments arrived.
26
27use std::collections::HashSet;
28
29use serde::{Deserialize, Serialize};
30
31use crate::components::{DestinationQueue, Direction, ElevatorPhase};
32use crate::entity::EntityId;
33use crate::world::{ExtKey, World};
34
35use super::{DispatchManifest, DispatchStrategy, ElevatorGroup, RankContext, pair_is_useful};
36
37/// Sticky rider → car assignment produced by [`DestinationDispatch`].
38///
39/// Stored as an extension component on the rider entity. Once set, the
40/// assignment is never mutated; the loading phase uses it to enforce
41/// that only the assigned car may board the rider.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub struct AssignedCar(pub EntityId);
44
45/// Typed extension key for [`AssignedCar`] storage.
46pub const ASSIGNED_CAR_KEY: ExtKey<AssignedCar> = ExtKey::new("assigned_car");
47
48/// Hall-call destination dispatch (DCS).
49///
50/// ## API shape
51///
52/// Uses [`DispatchStrategy::pre_dispatch`] to write sticky
53/// [`AssignedCar`] extensions and rebuild each car's committed stop
54/// queue during a `&mut World` phase. [`DispatchStrategy::rank`] then
55/// routes each car to its own queue front and returns `None` for every
56/// other stop, so the group-wide Hungarian assignment trivially pairs
57/// each car with the stop it has already committed to.
58#[derive(serde::Serialize, serde::Deserialize)]
59pub struct DestinationDispatch {
60 /// Weight for per-stop door overhead in the cost function. A positive
61 /// value biases assignments toward cars whose route change adds no
62 /// fresh stops; set via [`with_stop_penalty`](Self::with_stop_penalty).
63 ///
64 /// Units: ticks per newly-added stop. `None` ⇒ derive from the car's
65 /// own door timings (~`open + 2 * transition`).
66 stop_penalty: Option<f64>,
67 /// Deferred-commitment window. When `Some(window)`, a rider's
68 /// sticky assignment is re-evaluated each pass until the assigned
69 /// car is within `window` ticks of the rider's origin — modelling
70 /// KONE Polaris's two-button reallocation regime (DCS calls fix on
71 /// press; two-button hall calls re-allocate continuously until
72 /// commitment). `None` ⇒ immediate sticky (the default), matching
73 /// fixed-on-press DCS behavior.
74 commitment_window_ticks: Option<u64>,
75 /// Maximum candidate stops to consider per car when filling the
76 /// assignment cost matrix. Defaults to `Some(50)`; see
77 /// [`DispatchStrategy::candidate_limit`] for the rationale.
78 #[serde(default = "default_candidate_limit")]
79 candidate_limit: Option<usize>,
80}
81
82/// Serde default for [`DestinationDispatch::candidate_limit`] when
83/// restoring from a pre-pruning snapshot. Matches
84/// [`super::DEFAULT_CANDIDATE_LIMIT`].
85#[allow(clippy::unnecessary_wraps)] // serde default needs Option<usize>, not usize
86const fn default_candidate_limit() -> Option<usize> {
87 Some(super::DEFAULT_CANDIDATE_LIMIT)
88}
89
90impl DestinationDispatch {
91 /// Create a new `DestinationDispatch` with defaults (immediate sticky,
92 /// no commitment window).
93 #[must_use]
94 pub const fn new() -> Self {
95 Self {
96 stop_penalty: None,
97 commitment_window_ticks: None,
98 candidate_limit: Some(super::DEFAULT_CANDIDATE_LIMIT),
99 }
100 }
101
102 /// Override the fresh-stop penalty (ticks per new stop added to a
103 /// car's committed route when it picks this rider up).
104 #[must_use]
105 pub const fn with_stop_penalty(mut self, penalty: f64) -> Self {
106 self.stop_penalty = Some(penalty);
107 self
108 }
109
110 /// Enable deferred commitment: riders' sticky assignments are
111 /// re-evaluated each pass until the currently-assigned car is
112 /// within `window` ticks of the rider's origin. At that point the
113 /// commitment latches and later ticks leave the assignment alone.
114 #[must_use]
115 pub const fn with_commitment_window_ticks(mut self, window: u64) -> Self {
116 self.commitment_window_ticks = Some(window);
117 self
118 }
119
120 /// Set the per-car candidate limit for the assignment cost matrix.
121 /// `None` disables pruning entirely; defaults to `Some(50)`. See
122 /// [`DispatchStrategy::candidate_limit`] for the rationale.
123 #[must_use]
124 pub const fn with_candidate_limit(mut self, limit: Option<usize>) -> Self {
125 self.candidate_limit = limit;
126 self
127 }
128}
129
130impl Default for DestinationDispatch {
131 fn default() -> Self {
132 Self::new()
133 }
134}
135
136impl DispatchStrategy for DestinationDispatch {
137 #[allow(clippy::too_many_lines)]
138 fn pre_dispatch(
139 &mut self,
140 group: &ElevatorGroup,
141 manifest: &DispatchManifest,
142 world: &mut World,
143 ) {
144 // DCS requires the group to be in `HallCallMode::Destination` — that
145 // mode is what makes the kiosk-style "rider announces destination
146 // at press time" assumption hold. In Classic collective-control
147 // mode destinations aren't known until riders board, so running
148 // DCS there would commit assignments based on information a real
149 // controller wouldn't have. Early-return makes DCS a no-op for
150 // misconfigured groups; pair it with the right mode to activate.
151 if group.hall_call_mode() != super::HallCallMode::Destination {
152 return;
153 }
154
155 // Candidate cars in this group that are operable for dispatch.
156 let candidate_cars: Vec<EntityId> = group
157 .elevator_entities()
158 .iter()
159 .copied()
160 .filter(|eid| !world.is_disabled(*eid))
161 .filter(|eid| {
162 !world
163 .service_mode(*eid)
164 .is_some_and(|m| m.is_dispatch_excluded())
165 })
166 .filter(|eid| world.elevator(*eid).is_some())
167 .collect();
168
169 if candidate_cars.is_empty() {
170 return;
171 }
172
173 // Collect unassigned waiting riders in this group. A sticky
174 // assignment whose target car is dead or disabled is treated as
175 // void — re-assign rather than strand. (Lifecycle hooks in
176 // `disable`/`remove_elevator` normally clear these; this is the
177 // defense layer if cleanup is ever missed.)
178 let mut stale_assignments: Vec<EntityId> = Vec::new();
179 let mut pending: Vec<(EntityId, EntityId, EntityId, f64)> = Vec::new();
180 for (_, riders) in manifest.iter_waiting_stops() {
181 for info in riders {
182 if let Some(AssignedCar(c)) = world.ext::<AssignedCar>(info.id) {
183 // An assignment stays sticky only when the target
184 // car is still alive and (no commitment window is
185 // configured, or the car is already inside the
186 // latch window). Otherwise strip it so the rider
187 // re-competes below.
188 let alive = world.elevator(c).is_some() && !world.is_disabled(c);
189 let latched = self
190 .commitment_window_ticks
191 .is_none_or(|w| assigned_car_within_window(world, info.id, c, w));
192 if alive && latched {
193 continue; // sticky and live
194 }
195 stale_assignments.push(info.id);
196 }
197 let Some(dest) = info.destination else {
198 continue;
199 };
200 let Some(route) = world.route(info.id) else {
201 continue;
202 };
203 let Some(leg) = route.current() else {
204 continue;
205 };
206 if !group.accepts_leg(leg) {
207 continue;
208 }
209 pending.push((info.id, leg.from, dest, info.weight.value()));
210 }
211 }
212 pending.sort_by_key(|(rid, ..)| *rid);
213 // Drop stale extensions so subsequent ticks see them as unassigned.
214 for rid in stale_assignments {
215 world.remove_ext::<AssignedCar>(rid);
216 }
217
218 // Pre-compute committed-load per candidate car: aboard total
219 // (`current_load`) plus Waiting riders sticky-assigned to it.
220 // Terminal-phase riders whose `AssignedCar` was not cleaned up
221 // are filtered by the `RiderPhase::Waiting` check below.
222 let mut committed_load: std::collections::BTreeMap<EntityId, f64> =
223 std::collections::BTreeMap::new();
224 for &eid in &candidate_cars {
225 if let Some(car) = world.elevator(eid) {
226 committed_load.insert(eid, car.current_load().value());
227 }
228 }
229 let waiting_assignments: Vec<(EntityId, EntityId)> = world
230 .ext_map::<AssignedCar>()
231 .map(|m| m.iter().map(|(rid, AssignedCar(c))| (rid, *c)).collect())
232 .unwrap_or_default();
233 for (rid, car) in waiting_assignments {
234 if let Some(rider) = world.rider(rid)
235 && rider.phase() == crate::components::RiderPhase::Waiting
236 {
237 *committed_load.entry(car).or_insert(0.0) += rider.weight.value();
238 }
239 }
240
241 for (rid, origin, dest, weight) in pending {
242 let best = candidate_cars
243 .iter()
244 .filter_map(|&eid| {
245 let car = world.elevator(eid)?;
246 if car.restricted_stops().contains(&dest)
247 || car.restricted_stops().contains(&origin)
248 {
249 return None;
250 }
251 if car.weight_capacity().value() > 0.0 && weight > car.weight_capacity().value()
252 {
253 return None;
254 }
255 let com = committed_load.get(&eid).copied().unwrap_or(0.0);
256 let cost = self.compute_cost(eid, origin, dest, world, com);
257 if cost.is_finite() {
258 Some((eid, cost))
259 } else {
260 None
261 }
262 })
263 .min_by(|a, b| a.1.total_cmp(&b.1))
264 .map(|(eid, _)| eid);
265
266 let Some(car_eid) = best else {
267 continue;
268 };
269 world.insert_ext(rid, AssignedCar(car_eid), ASSIGNED_CAR_KEY);
270 *committed_load.entry(car_eid).or_insert(0.0) += weight;
271 }
272
273 // Rebuild each candidate car's destination queue from the current
274 // set of sticky commitments, arranged in direction-aware two-run
275 // monotone order. This is the source of truth per tick and avoids
276 // incremental-insertion drift (duplicates, orphaned entries).
277 for &car_eid in &candidate_cars {
278 rebuild_car_queue(world, group, car_eid);
279 }
280 }
281
282 fn rank(&self, ctx: &RankContext<'_>) -> Option<f64> {
283 // The queue is the source of truth — route each car strictly to
284 // its own queue front. Every other stop is unavailable for this
285 // car, so the Hungarian assignment reduces to the identity match
286 // between each car and the stop it has already committed to.
287 //
288 // The `pair_is_useful` gate guards against the same full-car
289 // self-assign stall the other built-ins close: a sticky DCS
290 // assignment whose car has filled up with earlier riders and
291 // whose queue front is still the *pickup* for an un-boarded
292 // rider would otherwise rank 0.0, win the Hungarian every tick,
293 // and cycle doors forever.
294 let front = ctx
295 .world
296 .destination_queue(ctx.car)
297 .and_then(DestinationQueue::front)?;
298 if front == ctx.stop && pair_is_useful(ctx, false) {
299 Some(0.0)
300 } else {
301 None
302 }
303 }
304
305 fn builtin_id(&self) -> Option<super::BuiltinStrategy> {
306 Some(super::BuiltinStrategy::Destination)
307 }
308
309 fn candidate_limit(&self) -> Option<usize> {
310 self.candidate_limit
311 }
312
313 fn snapshot_config(&self) -> Option<String> {
314 ron::to_string(self).ok()
315 }
316
317 fn restore_config(&mut self, serialized: &str) -> Result<(), String> {
318 let restored: Self = ron::from_str(serialized).map_err(|e| e.to_string())?;
319 *self = restored;
320 Ok(())
321 }
322}
323
324impl DestinationDispatch {
325 /// Compute the assignment cost of sending car `eid` to pick up a rider
326 /// whose route is `origin → dest`.
327 fn compute_cost(
328 &self,
329 eid: EntityId,
330 origin: EntityId,
331 dest: EntityId,
332 world: &World,
333 committed_load: f64,
334 ) -> f64 {
335 let Some(car) = world.elevator(eid) else {
336 return f64::INFINITY;
337 };
338 if car.max_speed().value() <= 0.0 {
339 return f64::INFINITY;
340 }
341
342 let Some(car_pos) = world.position(eid).map(|p| p.value) else {
343 return f64::INFINITY;
344 };
345 let Some(origin_pos) = world.stop_position(origin) else {
346 return f64::INFINITY;
347 };
348 let Some(dest_pos) = world.stop_position(dest) else {
349 return f64::INFINITY;
350 };
351
352 let door_overhead = f64::from(car.door_transition_ticks() * 2 + car.door_open_ticks());
353 let penalty = self.stop_penalty.unwrap_or_else(|| door_overhead.max(1.0));
354
355 // Pickup time: direct distance + per-stop door overhead for each
356 // committed stop that lies between the car and the origin.
357 let pickup_dist = (car_pos - origin_pos).abs();
358 let pickup_travel = pickup_dist / car.max_speed().value();
359 let intervening_committed = world.destination_queue(eid).map_or(0usize, |q| {
360 let (lo, hi) = if car_pos < origin_pos {
361 (car_pos, origin_pos)
362 } else {
363 (origin_pos, car_pos)
364 };
365 q.queue()
366 .iter()
367 .filter_map(|s| world.stop_position(*s))
368 .filter(|p| *p > lo + 1e-9 && *p < hi - 1e-9)
369 .count()
370 });
371 let pickup_time =
372 crate::fp::fma(intervening_committed as f64, door_overhead, pickup_travel);
373
374 // Ride time: origin → dest travel + door overhead at origin pickup.
375 let ride_dist = (origin_pos - dest_pos).abs();
376 let ride_time = ride_dist / car.max_speed().value() + door_overhead;
377
378 // Fresh stops added: 0, 1, or 2 depending on whether origin/dest
379 // are already queued for this car. Probe the queue slice directly
380 // instead of cloning it — `compute_cost` runs once per
381 // (car, candidate-rider) pair each DCS tick, and at the scale of a
382 // busy commercial group the Vec clone was the dominant allocation
383 // in `pre_dispatch`.
384 //
385 // Invariant: the destination queue must be immutable for the
386 // duration of one DCS pass. `pre_dispatch` calls `rebuild_car_queue`
387 // before any `compute_cost` runs, then leaves the queue alone
388 // until the next pass. Concurrent mutation here would race with
389 // these probes; if you ever need to write to the queue from
390 // inside a `compute_cost` call site, snapshot the relevant
391 // entries into a `SmallVec` at the top of `pre_dispatch` instead.
392 let queue_contains = |s: EntityId| {
393 world
394 .destination_queue(eid)
395 .is_some_and(|q| q.queue().contains(&s))
396 };
397 let mut new_stops = 0f64;
398 if !queue_contains(origin) {
399 new_stops += 1.0;
400 }
401 if dest != origin && !queue_contains(dest) {
402 new_stops += 1.0;
403 }
404
405 // Idle bias: empty cars get a small bonus so the load spreads.
406 // 10% of pickup travel time is enough to break ties towards an
407 // idle-and-near car over a slightly-closer-but-already-loaded
408 // peer, without overpowering the load_penalty term below for
409 // genuinely heavily-loaded cars.
410 let idle_bonus = if car.phase() == ElevatorPhase::Idle && car.riders().is_empty() {
411 -0.1 * pickup_travel
412 } else {
413 0.0
414 };
415
416 // Load bias: include both aboard and already-assigned-but-waiting
417 // riders so dispatch spreads load even before any boarding happens.
418 // The `* 4.0` multiplier on door overhead means a fully-loaded car
419 // pays roughly 4 extra door-cycles of cost relative to an empty
420 // peer; clamping `ratio` at 2.0 caps that penalty at 8 cycles to
421 // prevent over-committed cars from being effectively un-rankable.
422 let load_penalty = if car.weight_capacity().value() > 0.0 {
423 let effective = car.current_load().value().max(committed_load);
424 let ratio = (effective / car.weight_capacity().value()).min(2.0);
425 ratio * door_overhead * 4.0
426 } else {
427 0.0
428 };
429
430 pickup_time + ride_time + penalty * new_stops + idle_bonus + load_penalty
431 }
432}
433
434/// True when the `car` assigned to `rider` is within `window` ticks of
435/// the rider's origin, measured by raw distance / `max_speed`. Used to
436/// decide whether a deferred commitment has latched.
437fn assigned_car_within_window(
438 world: &crate::world::World,
439 rider: EntityId,
440 car: EntityId,
441 window: u64,
442) -> bool {
443 let Some(leg) = world.route(rider).and_then(|r| r.current()) else {
444 return false;
445 };
446 let Some(origin_pos) = world.stop_position(leg.from) else {
447 return false;
448 };
449 let Some(car_pos) = world.position(car).map(|p| p.value) else {
450 return false;
451 };
452 let Some(car_data) = world.elevator(car) else {
453 return false;
454 };
455 let speed = car_data.max_speed().value();
456 if !speed.is_finite() || speed <= 0.0 {
457 return false;
458 }
459 // `distance / speed` is seconds (speed is distance/second); convert
460 // to ticks so `window` is apples-to-apples. Same class of unit fix
461 // as ETD's door-cost conversion (see `etd.rs`). Fall back to 60 Hz
462 // for bare-World fixtures that don't seat a `TickRate` resource.
463 let tick_rate = world
464 .resource::<crate::time::TickRate>()
465 .map_or(60.0, |r| r.0);
466 let eta_ticks = (car_pos - origin_pos).abs() / speed * tick_rate;
467 // A non-finite ETA (NaN from corrupted position) would saturate
468 // the `as u64` cast to 0 and erroneously latch the commitment —
469 // refuse to latch instead.
470 if !eta_ticks.is_finite() {
471 return false;
472 }
473 eta_ticks.round() as u64 <= window
474}
475
476/// Drop every sticky [`AssignedCar`] assignment that points at `car_eid`.
477///
478/// Called by `Simulation::disable` and `Simulation::remove_elevator` when an
479/// elevator leaves service, so DCS-routed riders are not stranded behind a
480/// dead reference.
481pub fn clear_assignments_to(world: &mut crate::world::World, car_eid: EntityId) {
482 let stale: Vec<EntityId> = world
483 .ext_map::<AssignedCar>()
484 .map(|m| {
485 m.iter()
486 .filter_map(|(rid, AssignedCar(c))| (*c == car_eid).then_some(rid))
487 .collect()
488 })
489 .unwrap_or_default();
490 for rid in stale {
491 world.remove_ext::<AssignedCar>(rid);
492 }
493}
494
495/// Rebuild `car_eid`'s destination queue from all live sticky commitments.
496///
497/// Scans all riders assigned to this car and collects the set of stops it
498/// must visit:
499/// - waiting riders contribute both their origin and destination,
500/// - riding/boarding riders contribute just their destination (origin
501/// already visited).
502///
503/// The stops are then arranged into a two-run monotone sequence: the
504/// current sweep (in the car's current direction) followed by the reverse
505/// sweep. A third run is appended when a rider's trip reverses the sweep
506/// twice (origin behind, dest ahead of origin in the original sweep).
507#[allow(clippy::too_many_lines)]
508fn rebuild_car_queue(world: &mut crate::world::World, group: &ElevatorGroup, car_eid: EntityId) {
509 use crate::components::RiderPhase;
510
511 // Local type for gathered (origin?, dest) trips.
512 struct Trip {
513 origin: Option<EntityId>,
514 dest: EntityId,
515 }
516
517 let Some(car) = world.elevator(car_eid) else {
518 return;
519 };
520 let car_pos = world.position(car_eid).map_or(0.0, |p| p.value);
521 // Derive the sweep direction primarily from aboard-rider destinations,
522 // not the car's indicator lamps. Under heavy load on a single-car group
523 // the lamp state is itself a consequence of the previous rebuild, so
524 // lamp-driven `sweep_up` creates a self-reinforcing loop: a rebuild
525 // ordered around "current direction" keeps fresh pickups ahead of
526 // deliveries, which keeps the direction pointed at the pickups, which
527 // keeps the rebuild ordering them first. Letting aboard riders' dests
528 // pick the sweep breaks the loop — the car finishes delivering before
529 // it chases new pickups. Falls back to lamp direction when the car is
530 // empty (no aboard demand to break the tie).
531 let sweep_up = {
532 let mut aboard_up = 0u32;
533 let mut aboard_down = 0u32;
534 for &rid in car.riders() {
535 if let Some(dest) = world
536 .route(rid)
537 .and_then(crate::components::Route::current_destination)
538 && let Some(dp) = world.stop_position(dest)
539 {
540 if dp > car_pos + 1e-9 {
541 aboard_up += 1;
542 } else if dp < car_pos - 1e-9 {
543 aboard_down += 1;
544 }
545 }
546 }
547 match aboard_up.cmp(&aboard_down) {
548 std::cmp::Ordering::Greater => true,
549 std::cmp::Ordering::Less => false,
550 std::cmp::Ordering::Equal => {
551 matches!(car.direction(), Direction::Up | Direction::Either)
552 }
553 }
554 };
555
556 // Skip inserting a stop the car is currently parked at and loading.
557 let at_stop_loading: Option<EntityId> = {
558 let stopped_here = !matches!(
559 car.phase(),
560 ElevatorPhase::MovingToStop(_) | ElevatorPhase::Repositioning(_)
561 );
562 if stopped_here {
563 // Per-line lookup so a car parked at a sky-lobby served
564 // by multiple banks resolves to its own bank's stop.
565 let serves =
566 crate::dispatch::elevator_line_serves(world, std::slice::from_ref(group), car_eid);
567 serves.map_or_else(
568 || world.find_stop_at_position(car_pos),
569 |s| world.find_stop_at_position_in(car_pos, s),
570 )
571 } else {
572 None
573 }
574 };
575
576 // Gather (origin?, dest) pairs from sticky-assigned riders on this car.
577 let assigned_ids: Vec<EntityId> = world
578 .ext_map::<AssignedCar>()
579 .map(|m| {
580 m.iter()
581 .filter_map(|(rid, AssignedCar(c))| (*c == car_eid).then_some(rid))
582 .collect()
583 })
584 .unwrap_or_default();
585
586 let mut trips: Vec<Trip> = Vec::new();
587 for rid in assigned_ids {
588 let Some(rider) = world.rider(rid) else {
589 continue;
590 };
591 let Some(dest) = world
592 .route(rid)
593 .and_then(crate::components::Route::current_destination)
594 else {
595 continue;
596 };
597 match rider.phase() {
598 RiderPhase::Waiting => {
599 let origin = world
600 .route(rid)
601 .and_then(|r| r.current().map(|leg| leg.from));
602 // Strip origin if car is parked at it right now.
603 let origin = origin.filter(|o| Some(*o) != at_stop_loading);
604 trips.push(Trip { origin, dest });
605 }
606 RiderPhase::Boarding(_) | RiderPhase::Riding(_) => {
607 trips.push(Trip { origin: None, dest });
608 }
609 _ => {}
610 }
611 }
612
613 if trips.is_empty() {
614 if let Some(q) = world.destination_queue_mut(car_eid) {
615 q.clear();
616 }
617 return;
618 }
619
620 // Bucket each stop into up to three runs based on the car's direction:
621 // run1 = current sweep (same direction as car)
622 // run2 = reverse sweep
623 // run3 = second sweep in the original direction (for trips whose
624 // origin is behind the sweep but dest is further in it)
625 let mut run1: Vec<(EntityId, f64)> = Vec::new();
626 let mut run2: Vec<(EntityId, f64)> = Vec::new();
627 let mut run3: Vec<(EntityId, f64)> = Vec::new();
628
629 let in_run1 = |sp: f64| -> bool {
630 if sweep_up {
631 sp >= car_pos - 1e-9
632 } else {
633 sp <= car_pos + 1e-9
634 }
635 };
636
637 let push_unique = |v: &mut Vec<(EntityId, f64)>, s: EntityId, p: f64| {
638 if !v.iter().any(|(e, _)| *e == s) {
639 v.push((s, p));
640 }
641 };
642
643 for trip in &trips {
644 let dp = world.stop_position(trip.dest).unwrap_or(car_pos);
645 if let Some(o) = trip.origin {
646 let op = world.stop_position(o).unwrap_or(car_pos);
647 let o_in_run1 = in_run1(op);
648 let d_in_run1 = in_run1(dp);
649 if o_in_run1 {
650 push_unique(&mut run1, o, op);
651 if d_in_run1 {
652 // Both in run1: dest must be further in sweep than origin.
653 let d_fits = if sweep_up {
654 dp >= op - 1e-9
655 } else {
656 dp <= op + 1e-9
657 };
658 if d_fits {
659 push_unique(&mut run1, trip.dest, dp);
660 } else {
661 // Dest is behind origin in sweep: needs reverse run.
662 push_unique(&mut run2, trip.dest, dp);
663 }
664 } else {
665 push_unique(&mut run2, trip.dest, dp);
666 }
667 } else {
668 // Origin is behind sweep: both go in reverse/second run.
669 push_unique(&mut run2, o, op);
670 if d_in_run1 {
671 // Origin behind, dest ahead: need a third sweep.
672 push_unique(&mut run3, trip.dest, dp);
673 } else {
674 // Both behind sweep. Within reverse run, order dest
675 // after origin (dest further into reverse direction).
676 let d_further = if sweep_up {
677 dp <= op + 1e-9
678 } else {
679 dp >= op - 1e-9
680 };
681 if d_further {
682 push_unique(&mut run2, trip.dest, dp);
683 } else {
684 push_unique(&mut run3, trip.dest, dp);
685 }
686 }
687 }
688 } else {
689 // No origin: just drop off. Place dest in whichever run contains it.
690 if in_run1(dp) {
691 push_unique(&mut run1, trip.dest, dp);
692 } else {
693 push_unique(&mut run2, trip.dest, dp);
694 }
695 }
696 }
697
698 // Sort each run monotonically.
699 if sweep_up {
700 run1.sort_by(|a, b| a.1.total_cmp(&b.1));
701 run2.sort_by(|a, b| b.1.total_cmp(&a.1));
702 run3.sort_by(|a, b| a.1.total_cmp(&b.1));
703 } else {
704 run1.sort_by(|a, b| b.1.total_cmp(&a.1));
705 run2.sort_by(|a, b| a.1.total_cmp(&b.1));
706 run3.sort_by(|a, b| b.1.total_cmp(&a.1));
707 }
708
709 let mut out: Vec<EntityId> = Vec::with_capacity(run1.len() + run2.len() + run3.len());
710 out.extend(run1.into_iter().map(|(e, _)| e));
711 out.extend(run2.into_iter().map(|(e, _)| e));
712 out.extend(run3.into_iter().map(|(e, _)| e));
713 let mut seen = HashSet::with_capacity(out.len());
714 out.retain(|e| seen.insert(*e));
715
716 if let Some(q) = world.destination_queue_mut(car_eid) {
717 q.replace(out);
718 }
719}