Skip to main content

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