Skip to main content

rustsim_crowd/
threed.rs

1//! Layered 2.5-D crowd locomotion for multi-storey environments.
2//!
3//! In real buildings (stations, airports, malls, stadiums) pedestrians
4//! spend the vast majority of their time walking on a floor and only
5//! occasionally transition vertically via stairs, escalators, ramps, or
6//! lifts. Running full 3-D collision physics for every pedestrian
7//! all of the time is wasteful and numerically fragile.
8//!
9//! The industry-standard approach (used by JuPedSim, MassMotion, Legion)
10//! is **layered 2.5-D**: each pedestrian lives on a `floor` (integer
11//! index) and its planar `(x, y)` motion is advanced by one of the 2-D
12//! models in the crate (Social Force by default). Vertical motion is
13//! scripted by the `rustsim-mobility` crate through
14//! [`FloorTransition`]s — the pedestrian reaches a connector, is placed
15//! on the next floor, and its planar state is preserved.
16//!
17//! This module defines the data model:
18//!
19//! - [`Pedestrian3D`] — 2-D pose plus floor index and vertical offset.
20//! - [`WallPolygon3D`] — a polyline of 3-D segments, one per floor, used
21//!   by renderers and per-floor-physics setup.
22//! - [`FloorTransition`] — a connector (stair, escalator, ramp, lift)
23//!   that maps a 2-D region on floor A to a 2-D region on floor B with
24//!   an associated travel time.
25//! - [`LayeredSpace`] — a container of floors, walls, and connectors.
26//! - [`step_layered`] — advance every pedestrian by one tick, delegating
27//!   per-floor motion to a provided 2-D model step function.
28
29use rustsim_geometry::vec2::Vec2;
30use rustsim_geometry::vec3::Vec3;
31
32use crate::common::Pedestrian;
33use crate::common::WallSegment;
34
35/// Integer floor index. 0 = ground floor, 1 = first floor, etc.
36pub type FloorId = i32;
37
38/// A pedestrian in a layered 2.5-D environment.
39///
40/// Construct with [`Pedestrian3D::grounded`] or
41/// [`Pedestrian3D::heading_to_floor`] — the type is `#[non_exhaustive]`
42/// so future field additions (e.g. lift call-button state, floor
43/// preferences) are non-breaking for downstream callers.
44#[derive(Debug, Clone, Copy, PartialEq)]
45#[non_exhaustive]
46pub struct Pedestrian3D {
47    /// 2-D pose on the current floor.
48    pub base: Pedestrian,
49    /// Floor index.
50    pub floor: FloorId,
51    /// Vertical offset from the floor slab (m). Non-zero while on a
52    /// connector; 0 when grounded on the floor.
53    pub z_offset: f64,
54    /// Optional ongoing vertical transition.
55    pub transition: Option<ActiveTransition>,
56    /// Planner-owned floor intent. `Some(f)` means the pedestrian wants
57    /// to reach floor `f`; `None` means "stay on current floor".
58    ///
59    /// Boarding a connector is gated on `target_floor != Some(self.floor)`,
60    /// so an agent whose 2-D destination happens to overlap a boarding
61    /// zone on the same floor (or an agent that just alighted *onto*
62    /// that destination) will **not** re-enter the connector. This
63    /// fixes a long-standing re-entry bug in naïve spatial-only boarding.
64    pub target_floor: Option<FloorId>,
65}
66
67impl Pedestrian3D {
68    /// Convenience constructor grounded on `floor` with no vertical intent.
69    pub fn grounded(base: Pedestrian, floor: FloorId) -> Self {
70        Self {
71            base,
72            floor,
73            z_offset: 0.0,
74            transition: None,
75            target_floor: None,
76        }
77    }
78
79    /// Grounded constructor that explicitly requests a target floor.
80    pub fn heading_to_floor(base: Pedestrian, floor: FloorId, target: FloorId) -> Self {
81        Self {
82            base,
83            floor,
84            z_offset: 0.0,
85            transition: None,
86            target_floor: Some(target),
87        }
88    }
89
90    /// Full 3-D position, combining the floor elevation provided by the
91    /// [`LayeredSpace`] with the local 2-D pose and vertical offset.
92    pub fn pos_3d(&self, space: &LayeredSpace) -> Vec3 {
93        let z = space.floor_z(self.floor) + self.z_offset;
94        [self.base.pos[0], self.base.pos[1], z]
95    }
96}
97
98/// A polyline wall that spans a single floor.
99#[derive(Debug, Clone)]
100pub struct WallPolygon3D {
101    /// Which floor this polygon belongs to.
102    pub floor: FloorId,
103    /// Vertices in 2-D floor coordinates, forming a closed loop if the
104    /// first and last entries are equal.
105    pub vertices: Vec<Vec2>,
106}
107
108impl WallPolygon3D {
109    /// Expand this polygon into a sequence of [`WallSegment`]s consumable
110    /// by the 2-D physics models.
111    pub fn to_segments(&self) -> Vec<WallSegment> {
112        if self.vertices.len() < 2 {
113            return Vec::new();
114        }
115        self.vertices
116            .windows(2)
117            .map(|w| WallSegment { a: w[0], b: w[1] })
118            .collect()
119    }
120}
121
122/// Kind of vertical connector.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum ConnectorKind {
125    /// Bi-directional static stair.
126    Stair,
127    /// One-directional moving escalator.
128    Escalator,
129    /// Ramp (sloped floor).
130    Ramp,
131    /// Lift / elevator.
132    Lift,
133}
134
135/// A connector between two floors.
136#[derive(Debug, Clone)]
137pub struct FloorTransition {
138    /// Stable identifier.
139    pub id: u64,
140    /// Kind.
141    pub kind: ConnectorKind,
142    /// Floor where the connector begins.
143    pub from_floor: FloorId,
144    /// Centre of the boarding zone on the origin floor.
145    pub from_pos: Vec2,
146    /// Floor where the connector ends.
147    pub to_floor: FloorId,
148    /// Centre of the alighting zone on the destination floor.
149    pub to_pos: Vec2,
150    /// Zone radius within which boarding is accepted (m).
151    pub boarding_radius: f64,
152    /// Travel time from boarding to alighting (s).
153    pub travel_time: f64,
154}
155
156/// An active transition a pedestrian is currently riding.
157#[derive(Debug, Clone, Copy, PartialEq)]
158pub struct ActiveTransition {
159    /// Connector id.
160    pub connector_id: u64,
161    /// Seconds remaining until the pedestrian arrives on the destination floor.
162    pub remaining: f64,
163}
164
165/// A multi-floor environment.
166#[derive(Debug, Clone)]
167pub struct LayeredSpace {
168    /// Z coordinate of each floor slab (indexed by `FloorId`).
169    pub floor_elevations: Vec<(FloorId, f64)>,
170    /// Walls per floor.
171    pub walls: Vec<WallPolygon3D>,
172    /// Connectors between floors.
173    pub connectors: Vec<FloorTransition>,
174}
175
176impl LayeredSpace {
177    /// Create an empty layered space.
178    pub fn new() -> Self {
179        Self {
180            floor_elevations: Vec::new(),
181            walls: Vec::new(),
182            connectors: Vec::new(),
183        }
184    }
185
186    /// Register a floor at elevation `z`. Overwrites any previous entry.
187    pub fn set_floor(&mut self, floor: FloorId, z: f64) {
188        if let Some(entry) = self.floor_elevations.iter_mut().find(|(f, _)| *f == floor) {
189            entry.1 = z;
190        } else {
191            self.floor_elevations.push((floor, z));
192        }
193    }
194
195    /// Elevation of a floor (0.0 if unknown).
196    pub fn floor_z(&self, floor: FloorId) -> f64 {
197        self.floor_elevations
198            .iter()
199            .find(|(f, _)| *f == floor)
200            .map(|(_, z)| *z)
201            .unwrap_or(0.0)
202    }
203
204    /// Gather walls on a specific floor as 2-D segments.
205    pub fn segments_on_floor(&self, floor: FloorId) -> Vec<WallSegment> {
206        let mut out = Vec::new();
207        for p in &self.walls {
208            if p.floor == floor {
209                out.extend(p.to_segments());
210            }
211        }
212        out
213    }
214
215    /// Connector with a matching id, if any.
216    pub fn connector(&self, id: u64) -> Option<&FloorTransition> {
217        self.connectors.iter().find(|c| c.id == id)
218    }
219
220    /// Pick the first connector on `floor` whose boarding zone contains `pos`.
221    pub fn connector_at(&self, floor: FloorId, pos: Vec2) -> Option<&FloorTransition> {
222        self.connectors.iter().find(|c| {
223            c.from_floor == floor
224                && rustsim_geometry::vec2::distance(pos, c.from_pos) <= c.boarding_radius
225        })
226    }
227}
228
229impl Default for LayeredSpace {
230    fn default() -> Self {
231        Self::new()
232    }
233}
234
235/// Function that advances a slice of 2-D pedestrians on a single floor by `dt`.
236///
237/// The crate provides one for each 2-D model — e.g. pass
238/// `crate::social_force::step` to use the Social Force physics.
239pub type PlanarStepFn = fn(&mut [Pedestrian], &[WallSegment], &crate::social_force::Params, f64);
240
241/// Advance every pedestrian in `peds` by `dt` using `planar_step` as the
242/// per-floor physics and `space` for walls and connectors.
243///
244/// Semantics:
245/// 1. Pedestrians currently riding a connector have their `remaining` time
246///    decremented; when it reaches zero they are teleported to the
247///    connector's `to_floor` at `to_pos` and grounded.
248/// 2. Grounded pedestrians are grouped by floor. For each floor the
249///    provided planar step function is called with that floor's walls.
250/// 3. After planar motion, each grounded pedestrian is checked against
251///    the floor's connectors; if its 2-D position lands inside a
252///    connector's boarding zone *and* its target is on the connector's
253///    destination floor, it boards (a new [`ActiveTransition`] begins).
254///
255/// Boarding is only triggered for pedestrians whose target destination
256/// (z-planar) is on a different floor; the selection of *which* connector
257/// to take is the responsibility of the `rustsim-mobility` crate, which
258/// would instead set the pedestrian's `destination` to the connector's
259/// `from_pos` directly.
260/// Reusable scratch buffers for [`step_layered_scratch`].
261///
262/// `step_layered` is convenient but allocates four `Vec`s per tick
263/// (`floors`, `idxs`, `buf`, `walls`). For a 30–60 Hz station sim with
264/// multiple floors this is measurable allocator traffic. Allocate one
265/// `LayeredScratch` per simulation, pass it to [`step_layered_scratch`]
266/// every tick, and the heap is untouched on the steady state.
267///
268/// The individual fields are `pub(crate)` so the wrapper can reuse
269/// them; external callers should treat the type as opaque.
270#[derive(Debug, Default)]
271pub struct LayeredScratch {
272    pub(crate) floors: Vec<FloorId>,
273    pub(crate) idxs: Vec<usize>,
274    pub(crate) buf: Vec<Pedestrian>,
275    pub(crate) walls: Vec<WallSegment>,
276}
277
278impl LayeredScratch {
279    /// Create an empty scratch with the default capacities.
280    pub fn new() -> Self {
281        Self::default()
282    }
283
284    /// Create a scratch pre-sized for `n_peds` pedestrians. `Vec`s are
285    /// only reserved, not populated.
286    pub fn with_capacity(n_peds: usize) -> Self {
287        Self {
288            floors: Vec::with_capacity(4),
289            idxs: Vec::with_capacity(n_peds),
290            buf: Vec::with_capacity(n_peds),
291            walls: Vec::with_capacity(32),
292        }
293    }
294}
295
296/// Advance every pedestrian in `peds` by `dt`. Convenience wrapper that
297/// allocates one [`LayeredScratch`] per call and immediately drops it;
298/// prefer [`step_layered_scratch`] when the same simulation drives
299/// successive ticks.
300pub fn step_layered(
301    peds: &mut [Pedestrian3D],
302    space: &LayeredSpace,
303    planar_step: PlanarStepFn,
304    params: &crate::social_force::Params,
305    dt: f64,
306) {
307    let mut scratch = LayeredScratch::with_capacity(peds.len());
308    step_layered_scratch(peds, space, planar_step, params, dt, &mut scratch);
309}
310
311/// Zero-allocation variant of [`step_layered`].
312///
313/// Semantics are identical to [`step_layered`]; the only difference is
314/// that all working buffers are borrowed from `scratch` and their
315/// capacities are preserved across calls.
316pub fn step_layered_scratch(
317    peds: &mut [Pedestrian3D],
318    space: &LayeredSpace,
319    planar_step: PlanarStepFn,
320    params: &crate::social_force::Params,
321    dt: f64,
322    scratch: &mut LayeredScratch,
323) {
324    step_layered_scratch_observed(
325        peds,
326        space,
327        planar_step,
328        params,
329        dt,
330        scratch,
331        &mut NoopLayeredObserver,
332    );
333}
334
335/// Per-pedestrian post-step observation hook for the layered 2.5-D
336/// drive.
337///
338/// Mirrors [`crate::integration::CrowdObserver`] for the 2-D
339/// `AgentStore` path: closes the layered half of the P1-6 "telemetry
340/// hooks" item from `docs/rustsim-crowd.md`. Invoked by
341/// [`step_layered_scratch_observed`] **after** every tick stage has
342/// completed (rider advance, per-floor physics, boarding decisions),
343/// so the `Pedestrian3D` argument carries the authoritative
344/// post-tick `floor`, `z_offset`, `transition`, `target_floor`, and
345/// planar state. The `index` argument is the position of the
346/// pedestrian inside `peds`, which is stable across the call (the
347/// layered drive never reorders the slice).
348///
349/// The trait is blanket-implemented for every `FnMut(usize,
350/// &Pedestrian3D)`, so callers typically pass a closure that
351/// forwards into a `TelemetryPipeline`, a per-floor occupancy
352/// counter, or a CSV logger — without `rustsim-crowd` itself taking
353/// a dependency on any sink implementation.
354///
355/// Observation order is `0..peds.len()`, deterministic on every run.
356pub trait LayeredObserver {
357    /// Observe the post-tick state of one pedestrian.
358    fn observe(&mut self, index: usize, ped: &Pedestrian3D);
359}
360
361impl<F> LayeredObserver for F
362where
363    F: FnMut(usize, &Pedestrian3D),
364{
365    #[inline]
366    fn observe(&mut self, index: usize, ped: &Pedestrian3D) {
367        self(index, ped);
368    }
369}
370
371/// No-op observer used by [`step_layered_scratch`] to share code
372/// with [`step_layered_scratch_observed`]. Monomorphisation erases
373/// the observer entirely in the non-observing path.
374#[derive(Debug, Clone, Copy, Default)]
375struct NoopLayeredObserver;
376
377impl LayeredObserver for NoopLayeredObserver {
378    #[inline]
379    fn observe(&mut self, _index: usize, _ped: &Pedestrian3D) {}
380}
381
382/// Observed variant of [`step_layered_scratch`]: identical semantics,
383/// plus a post-tick callback for every pedestrian.
384///
385/// `observer.observe(i, &peds[i])` is invoked once per pedestrian,
386/// in `0..peds.len()` order, **after** all three stages of the tick
387/// have completed (rider advance, per-floor planar step, boarding).
388/// This is the production telemetry entry point for the 2.5-D
389/// drive: a closure that forwards into
390/// `rustsim::TelemetryPipeline::push_row` (or any other sink — CSV,
391/// Parquet, in-memory floor-occupancy counter) gets per-agent
392/// per-tick coverage with zero allocation on the hot path beyond
393/// what the sink itself may do.
394///
395/// Panic safety: if `observer.observe` panics the callback loop
396/// unwinds; the `peds` slice is left in its full post-tick state
397/// because the observer runs after every write-back.
398#[allow(clippy::too_many_arguments)]
399pub fn step_layered_scratch_observed<O>(
400    peds: &mut [Pedestrian3D],
401    space: &LayeredSpace,
402    planar_step: PlanarStepFn,
403    params: &crate::social_force::Params,
404    dt: f64,
405    scratch: &mut LayeredScratch,
406    observer: &mut O,
407) where
408    O: LayeredObserver + ?Sized,
409{
410    step_layered_scratch_inner(peds, space, planar_step, params, dt, scratch);
411    for (i, ped) in peds.iter().enumerate() {
412        observer.observe(i, ped);
413    }
414}
415
416fn step_layered_scratch_inner(
417    peds: &mut [Pedestrian3D],
418    space: &LayeredSpace,
419    planar_step: PlanarStepFn,
420    params: &crate::social_force::Params,
421    dt: f64,
422    scratch: &mut LayeredScratch,
423) {
424    // 1. Advance riders and complete transitions.
425    for p in peds.iter_mut() {
426        if let Some(t) = p.transition.as_mut() {
427            t.remaining -= dt;
428            if t.remaining <= 0.0 {
429                if let Some(c) = space.connector(t.connector_id) {
430                    p.floor = c.to_floor;
431                    p.base.pos = c.to_pos;
432                    p.base.vel = [0.0, 0.0];
433                    p.z_offset = 0.0;
434                }
435                p.transition = None;
436            } else if let Some(c) = space.connector(t.connector_id) {
437                // Linearly interpolate z_offset for visualisation.
438                let travel = c.travel_time.max(1e-9);
439                let t_frac = 1.0 - (t.remaining / travel).clamp(0.0, 1.0);
440                let dz = space.floor_z(c.to_floor) - space.floor_z(c.from_floor);
441                p.z_offset = t_frac * dz;
442            }
443        }
444    }
445
446    // 2. Group grounded pedestrians by floor (reuse scratch.floors).
447    scratch.floors.clear();
448    for p in peds.iter() {
449        if p.transition.is_none() && !scratch.floors.contains(&p.floor) {
450            scratch.floors.push(p.floor);
451        }
452    }
453    scratch.floors.sort();
454
455    // Iterate by index (rather than `for floor in &scratch.floors`) so
456    // the scratch can be re-borrowed for `idxs` / `buf` / `walls`.
457    let n_floors = scratch.floors.len();
458    for fi in 0..n_floors {
459        let floor = scratch.floors[fi];
460
461        scratch.idxs.clear();
462        scratch.buf.clear();
463        for (i, p) in peds.iter().enumerate() {
464            if p.transition.is_none() && p.floor == floor {
465                scratch.idxs.push(i);
466                scratch.buf.push(p.base);
467            }
468        }
469
470        scratch.walls.clear();
471        for polygon in &space.walls {
472            if polygon.floor == floor {
473                // `to_segments` still allocates, but it's a property of
474                // the space rather than of the pedestrian pass; inline
475                // to skip the intermediate Vec.
476                if polygon.vertices.len() >= 2 {
477                    for w in polygon.vertices.windows(2) {
478                        scratch.walls.push(WallSegment { a: w[0], b: w[1] });
479                    }
480                }
481            }
482        }
483
484        planar_step(&mut scratch.buf, &scratch.walls, params, dt);
485
486        for (k, &i) in scratch.idxs.iter().enumerate() {
487            peds[i].base = scratch.buf[k];
488        }
489    }
490
491    // 3. Board connectors where applicable.
492    for p in peds.iter_mut() {
493        if p.transition.is_some() {
494            continue;
495        }
496        // Boarding is only valid if the planner explicitly wants the
497        // pedestrian on a different floor. This prevents the classic
498        // "alight → re-board immediately" loop that occurs when a
499        // pedestrian's destination lies inside (or next to) a
500        // connector boarding zone on the destination floor.
501        let wants_transfer = match p.target_floor {
502            Some(f) => f != p.floor,
503            None => false,
504        };
505        if !wants_transfer {
506            continue;
507        }
508        if let Some(c) = space.connector_at(p.floor, p.base.pos) {
509            // Only board connectors whose `to_floor` matches the
510            // planner intent. A multi-hop plan should update
511            // `target_floor` after each alighting.
512            if Some(c.to_floor) != p.target_floor {
513                continue;
514            }
515            p.transition = Some(ActiveTransition {
516                connector_id: c.id,
517                remaining: c.travel_time,
518            });
519        }
520    }
521}
522
523#[cfg(test)]
524#[allow(deprecated)] // intentional: pins 3D-projection equivalence vs the deprecated O(n²) `step`.
525mod tests {
526    use super::*;
527    use crate::common::Pedestrian;
528    use crate::social_force;
529
530    fn ped(pos: Vec2, dest: Vec2) -> Pedestrian {
531        Pedestrian {
532            pos,
533            vel: [0.0, 0.0],
534            radius: 0.25,
535            desired_speed: 1.34,
536            destination: dest,
537        }
538    }
539
540    fn two_floor_space() -> LayeredSpace {
541        let mut s = LayeredSpace::new();
542        s.set_floor(0, 0.0);
543        s.set_floor(1, 4.0);
544        s.connectors.push(FloorTransition {
545            id: 1,
546            kind: ConnectorKind::Escalator,
547            from_floor: 0,
548            from_pos: [10.0, 0.0],
549            to_floor: 1,
550            to_pos: [10.0, 0.0],
551            boarding_radius: 0.4,
552            travel_time: 10.0,
553        });
554        s
555    }
556
557    #[test]
558    fn grounded_pedestrian_moves_on_its_floor() {
559        let space = two_floor_space();
560        let mut peds = vec![Pedestrian3D::grounded(ped([0.0, 0.0], [5.0, 0.0]), 0)];
561        for _ in 0..30 {
562            step_layered(
563                &mut peds,
564                &space,
565                social_force::step,
566                &social_force::Params::default(),
567                0.1,
568            );
569        }
570        assert!(peds[0].base.pos[0] > 0.5);
571        assert_eq!(peds[0].floor, 0);
572    }
573
574    #[test]
575    fn boarding_and_alighting_transitions_between_floors() {
576        let space = two_floor_space();
577        let mut peds = vec![Pedestrian3D::heading_to_floor(
578            ped([10.0, 0.0], [10.0, 0.0]),
579            0,
580            1,
581        )];
582        // First step: triggers boarding.
583        step_layered(
584            &mut peds,
585            &space,
586            social_force::step,
587            &social_force::Params::default(),
588            0.1,
589        );
590        assert!(peds[0].transition.is_some());
591        // Many steps: ride out the 10s travel time.
592        for _ in 0..200 {
593            step_layered(
594                &mut peds,
595                &space,
596                social_force::step,
597                &social_force::Params::default(),
598                0.1,
599            );
600        }
601        assert!(peds[0].transition.is_none());
602        assert_eq!(peds[0].floor, 1);
603    }
604
605    #[test]
606    fn does_not_reboard_after_alighting_into_same_boarding_zone() {
607        // Regression: a pedestrian whose 2-D destination on the upper
608        // floor lies inside the connector's boarding zone used to
609        // re-board the connector every tick forever. Now that
610        // boarding is gated on `target_floor`, alighting clears the
611        // intent (planner's responsibility) and the ride-back loop
612        // is impossible with a single-hop intent.
613        let space = two_floor_space();
614        let mut peds = vec![Pedestrian3D::heading_to_floor(
615            ped([10.0, 0.0], [10.0, 0.0]),
616            0,
617            1,
618        )];
619        // Board, ride, alight.
620        for _ in 0..200 {
621            step_layered(
622                &mut peds,
623                &space,
624                social_force::step,
625                &social_force::Params::default(),
626                0.1,
627            );
628        }
629        assert_eq!(peds[0].floor, 1);
630
631        // Planner reached its target, clears the intent.
632        peds[0].target_floor = None;
633
634        // Now drive many more ticks with the pedestrian sitting on
635        // top of floor 1's side of the connector. It must never
636        // re-board.
637        for _ in 0..500 {
638            step_layered(
639                &mut peds,
640                &space,
641                social_force::step,
642                &social_force::Params::default(),
643                0.1,
644            );
645            assert!(peds[0].transition.is_none(), "pedestrian re-boarded");
646            assert_eq!(peds[0].floor, 1);
647        }
648    }
649
650    #[test]
651    fn pos_3d_combines_floor_elevation_and_z_offset() {
652        let space = two_floor_space();
653        let p = Pedestrian3D {
654            base: ped([1.0, 2.0], [0.0, 0.0]),
655            floor: 1,
656            z_offset: 0.5,
657            transition: None,
658            target_floor: None,
659        };
660        let p3 = p.pos_3d(&space);
661        assert_eq!(p3, [1.0, 2.0, 4.5]);
662    }
663
664    #[test]
665    fn step_layered_scratch_matches_step_layered_bit_exact() {
666        // Seed a heterogeneous state (both floors, one rider) and verify
667        // scratched vs non-scratched variants produce bit-identical output.
668        let space = two_floor_space();
669        let params = social_force::Params::default();
670
671        let seed_peds = || -> Vec<Pedestrian3D> {
672            vec![
673                Pedestrian3D::heading_to_floor(ped([0.0, 0.0], [10.0, 0.0]), 0, 1),
674                Pedestrian3D::heading_to_floor(ped([2.0, 1.0], [10.0, 0.0]), 0, 1),
675                Pedestrian3D::grounded(ped([5.0, -0.5], [20.0, 0.0]), 0),
676                Pedestrian3D::grounded(ped([7.0, 0.5], [-5.0, 0.0]), 1),
677            ]
678        };
679
680        let mut a = seed_peds();
681        let mut b = seed_peds();
682        let mut scratch = LayeredScratch::with_capacity(a.len());
683
684        for _ in 0..50 {
685            step_layered(&mut a, &space, social_force::step, &params, 0.1);
686            step_layered_scratch(
687                &mut b,
688                &space,
689                social_force::step,
690                &params,
691                0.1,
692                &mut scratch,
693            );
694        }
695
696        assert_eq!(a.len(), b.len());
697        for (pa, pb) in a.iter().zip(b.iter()) {
698            assert_eq!(pa.floor, pb.floor);
699            assert_eq!(pa.base.pos, pb.base.pos);
700            assert_eq!(pa.base.vel, pb.base.vel);
701            assert_eq!(pa.transition.is_some(), pb.transition.is_some());
702        }
703    }
704
705    #[test]
706    fn layered_scratch_reuses_capacity_across_ticks() {
707        let space = two_floor_space();
708        let params = social_force::Params::default();
709        let mut peds = vec![
710            Pedestrian3D::grounded(ped([0.0, 0.0], [5.0, 0.0]), 0),
711            Pedestrian3D::grounded(ped([1.0, 1.0], [5.0, 0.0]), 0),
712            Pedestrian3D::grounded(ped([2.0, -1.0], [5.0, 0.0]), 1),
713        ];
714        let mut scratch = LayeredScratch::with_capacity(peds.len());
715
716        // First tick allocates up to the working-set sizes.
717        step_layered_scratch(
718            &mut peds,
719            &space,
720            social_force::step,
721            &params,
722            0.1,
723            &mut scratch,
724        );
725        let (cap_floors, cap_idxs, cap_buf, cap_walls) = (
726            scratch.floors.capacity(),
727            scratch.idxs.capacity(),
728            scratch.buf.capacity(),
729            scratch.walls.capacity(),
730        );
731
732        // Subsequent ticks must not grow any of the reusable buffers.
733        for _ in 0..20 {
734            step_layered_scratch(
735                &mut peds,
736                &space,
737                social_force::step,
738                &params,
739                0.1,
740                &mut scratch,
741            );
742        }
743        assert_eq!(scratch.floors.capacity(), cap_floors);
744        assert_eq!(scratch.idxs.capacity(), cap_idxs);
745        assert_eq!(scratch.buf.capacity(), cap_buf);
746        assert_eq!(scratch.walls.capacity(), cap_walls);
747    }
748
749    #[test]
750    fn observed_layered_step_matches_unobserved_and_streams_in_index_order() {
751        // Pin the layered observer contract:
752        //
753        //   1. `step_layered_scratch_observed` produces the same
754        //      post-tick `peds` slice as `step_layered_scratch` for
755        //      identical inputs (the observer is purely a callback,
756        //      it must not perturb state);
757        //   2. the observer sees one row per pedestrian per tick, in
758        //      `0..peds.len()` order, with the **post-tick**
759        //      `Pedestrian3D` (so transitions, floor moves, and
760        //      planar updates are all visible to telemetry);
761        //   3. the boarding gate continues to fire under the
762        //      observer (i.e. the rewrite did not regress the
763        //      `target_floor` semantics from blocker #8).
764        let space = two_floor_space();
765        let params = social_force::Params::default();
766
767        let make_peds = || {
768            vec![
769                // Heading from floor 0 to floor 1 via the connector
770                // at [10, 0]; should board within a few ticks.
771                Pedestrian3D::heading_to_floor(ped([9.7, 0.0], [10.0, 0.0]), 0, 1),
772                // Stays on floor 0; just walks a bit.
773                Pedestrian3D::grounded(ped([0.0, 0.0], [3.0, 0.0]), 0),
774                // Already on floor 1, no transfer intent.
775                Pedestrian3D::grounded(ped([0.0, 5.0], [3.0, 5.0]), 1),
776            ]
777        };
778
779        let mut peds_a = make_peds();
780        let mut peds_b = make_peds();
781        let mut scratch_a = LayeredScratch::with_capacity(peds_a.len());
782        let mut scratch_b = LayeredScratch::with_capacity(peds_b.len());
783
784        // Each tick the observer logs (index, post-tick floor,
785        // post-tick `transition.is_some()`, post-tick pos[0]).
786        let mut log: Vec<(usize, FloorId, bool, f64)> = Vec::new();
787
788        let total_ticks = 20;
789        for _ in 0..total_ticks {
790            step_layered_scratch(
791                &mut peds_a,
792                &space,
793                social_force::step,
794                &params,
795                0.1,
796                &mut scratch_a,
797            );
798            step_layered_scratch_observed(
799                &mut peds_b,
800                &space,
801                social_force::step,
802                &params,
803                0.1,
804                &mut scratch_b,
805                &mut |i: usize, ped: &Pedestrian3D| {
806                    log.push((i, ped.floor, ped.transition.is_some(), ped.base.pos[0]));
807                },
808            );
809        }
810
811        // (1) identical post-tick state.
812        assert_eq!(
813            peds_a, peds_b,
814            "observed step must not perturb the underlying tick"
815        );
816        // (2) the log has exactly `n_peds * total_ticks` rows in
817        //     deterministic index-major / tick-major order.
818        let n = peds_a.len();
819        assert_eq!(log.len(), n * total_ticks);
820        for (row_idx, row) in log.iter().enumerate() {
821            assert_eq!(row.0, row_idx % n, "observer must stream in index order");
822        }
823        // (3) the boarding gate still fires: agent 0 must end up on
824        //     floor 1 (or in transition to it) by the end. The log's
825        //     last entry for agent 0 carries the final state.
826        let last_for_zero = log
827            .iter()
828            .rev()
829            .find(|r| r.0 == 0)
830            .expect("agent 0 must appear in the log");
831        assert!(
832            last_for_zero.1 == 1 || last_for_zero.2,
833            "agent 0 should have boarded the connector or already alighted on floor 1, got {:?}",
834            last_for_zero
835        );
836    }
837}