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, ¶ms, 0.1);
686 step_layered_scratch(
687 &mut b,
688 &space,
689 social_force::step,
690 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
795 0.1,
796 &mut scratch_a,
797 );
798 step_layered_scratch_observed(
799 &mut peds_b,
800 &space,
801 social_force::step,
802 ¶ms,
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}