Skip to main content

rustsim_spaces/
hybrid.rs

1//! Hybrid space combining a `GraphSpace` topology with `ContinuousSpace3D`
2//! surfaces at each graph node.
3//!
4//! Each graph node represents a zone (room, corridor, terrain patch, etc.)
5//! with its own 3D bounding box. Agents have both a graph position (which
6//! zone they occupy) and a continuous 3D position within that zone.
7//!
8//! Movement within a zone uses continuous 3D displacement. Movement between
9//! zones follows graph edges.
10//!
11//! # Current semantic scope
12//!
13//! `HybridSpace` guarantees:
14//! - one owning zone per agent
15//! - graph-constrained inter-zone transitions
16//! - zone-local 3D movement and obstacle checks
17//! - same-zone Euclidean queries
18//!
19//! `HybridSpace` does **not** define a global geometric embedding across zones.
20//! As a result, cross-zone queries are topological rather than geometric unless
21//! client code supplies a higher-level portal/global-coordinate interpretation.
22//!
23//! ## Relationship to `GraphSpace` and `ContinuousSpace3D`
24//!
25//! `HybridSpace` **composes** `GraphSpace` for the graph topology (edges,
26//! BFS, directed/undirected) -- it delegates all graph operations to an
27//! internal `GraphSpace` instance. It does **not** compose `ContinuousSpace3D`
28//! because:
29//!
30//! - Each zone has a different extent (rooms, corridors, stairwells).
31//! - Agent IDs must be globally unique across zones with single-owner tracking.
32//! - Walkmap integration is per-zone, tightly coupled to movement validation.
33//! - For zones with many agents, an optional per-zone spatial hash provides
34//!   O(k) neighbor queries without the overhead of N separate `ContinuousSpace3D`
35//!   instances.
36//!
37//! ## Obstacle Maps
38//!
39//! Zones can optionally carry a 3D voxel walkability grid via
40//! [`set_zone_walkmap`](HybridSpace::set_zone_walkmap). When present,
41//! movement functions ([`move_within_zone`](HybridSpace::move_within_zone),
42//! [`move_to_offset`](HybridSpace::move_to_offset)) reject displacements
43//! into non-walkable voxels, keeping the agent at its previous position.
44//!
45//! The walkmap format is identical to the one used by
46//! [`ContinuousAStar3D`](crate::continuous3d) in `rustsim-pathfinding`:
47//! a flat `Vec<bool>` of size `gw * gh * gd`, indexed as
48//! `walkmap[z * gh * gw + y * gw + x]`.
49//!
50//! ## Per-Zone Spatial Hashing
51//!
52//! For zones with many agents (stadium concourses, large open areas),
53//! call [`enable_zone_spatial_hash`](HybridSpace::enable_zone_spatial_hash)
54//! with a cell spacing. This accelerates within-zone Euclidean neighbor
55//! queries from O(n) to O(k) using the same uniform-grid spatial hashing
56//! algorithm as `ContinuousSpace3D`.
57//!
58//! Zones without spatial hashing use linear scan, which is faster for
59//! small populations (< ~500 agents per zone).
60//!
61//! Neighbor queries combine graph-hop reachability with zone-local geometric
62//! filtering only for the origin zone.
63
64use rand::Rng;
65use rustsim_core::{
66    interaction::{PositionedAgent, SpaceInteraction},
67    space::Space,
68    types::{AgentId, ZoneId},
69};
70use std::collections::{HashMap, HashSet, VecDeque};
71use thiserror::Error;
72
73use crate::continuous3d::ContinuousPos3D;
74use crate::graph::{GraphPos, GraphSpace, NeighborType};
75
76// ---------------------------------------------------------------------------
77// Position type
78// ---------------------------------------------------------------------------
79
80/// Position in the hybrid space: a graph node plus a 3D offset within that node's zone.
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub struct HybridPos {
83    /// Which graph node (zone) the agent occupies.
84    pub node: ZoneId,
85    /// Continuous 3D position within the zone's local coordinate system.
86    pub offset: ContinuousPos3D,
87}
88
89impl HybridPos {
90    pub fn new(node: ZoneId, offset: ContinuousPos3D) -> Self {
91        Self { node, offset }
92    }
93
94    pub fn at_node(node: ZoneId) -> Self {
95        Self {
96            node,
97            offset: ContinuousPos3D::zero(),
98        }
99    }
100}
101
102// ---------------------------------------------------------------------------
103// Zone descriptor
104// ---------------------------------------------------------------------------
105
106/// Errors returned by zone-extent validation.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
108pub enum ZoneExtentError {
109    #[error("zone extents must be positive")]
110    InvalidExtent,
111}
112
113/// Describes the 3D extent of a zone attached to a graph node.
114#[derive(Debug, Clone, Copy)]
115pub struct ZoneExtent {
116    pub size_x: f64,
117    pub size_y: f64,
118    pub size_z: f64,
119}
120
121impl ZoneExtent {
122    pub fn new(size_x: f64, size_y: f64, size_z: f64) -> Result<Self, ZoneExtentError> {
123        if size_x <= 0.0 || size_y <= 0.0 || size_z <= 0.0 {
124            return Err(ZoneExtentError::InvalidExtent);
125        }
126        Ok(Self {
127            size_x,
128            size_y,
129            size_z,
130        })
131    }
132
133    pub fn cube(size: f64) -> Result<Self, ZoneExtentError> {
134        Self::new(size, size, size)
135    }
136
137    pub fn contains(&self, p: &ContinuousPos3D) -> bool {
138        p.x >= 0.0
139            && p.x < self.size_x
140            && p.y >= 0.0
141            && p.y < self.size_y
142            && p.z >= 0.0
143            && p.z < self.size_z
144    }
145
146    pub fn clamp(&self, p: &ContinuousPos3D) -> ContinuousPos3D {
147        ContinuousPos3D::new(
148            p.x.clamp(0.0, self.size_x - 1e-9),
149            p.y.clamp(0.0, self.size_y - 1e-9),
150            p.z.clamp(0.0, self.size_z - 1e-9),
151        )
152    }
153
154    pub fn center(&self) -> ContinuousPos3D {
155        ContinuousPos3D::new(self.size_x / 2.0, self.size_y / 2.0, self.size_z / 2.0)
156    }
157}
158
159impl Default for ZoneExtent {
160    fn default() -> Self {
161        Self {
162            size_x: 10.0,
163            size_y: 10.0,
164            size_z: 10.0,
165        }
166    }
167}
168
169// ---------------------------------------------------------------------------
170// Zone walkability map
171// ---------------------------------------------------------------------------
172
173/// Errors returned by zone-walkmap validation.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
175pub enum ZoneWalkmapError {
176    #[error("walkmap dimensions must be positive")]
177    InvalidDimensions,
178    #[error("walkmap size mismatch: expected {expected}, got {actual}")]
179    SizeMismatch { expected: usize, actual: usize },
180}
181
182/// Optional 3D voxel walkability grid for a zone.
183#[derive(Debug, Clone)]
184pub struct ZoneWalkmap {
185    grid_w: usize,
186    grid_h: usize,
187    grid_d: usize,
188    walkmap: Vec<bool>,
189}
190
191impl ZoneWalkmap {
192    /// Create a walkmap for a zone.
193    pub fn new(
194        walkmap: Vec<bool>,
195        grid_w: usize,
196        grid_h: usize,
197        grid_d: usize,
198    ) -> Result<Self, ZoneWalkmapError> {
199        if grid_w == 0 || grid_h == 0 || grid_d == 0 {
200            return Err(ZoneWalkmapError::InvalidDimensions);
201        }
202        let expected = grid_w * grid_h * grid_d;
203        if walkmap.len() != expected {
204            return Err(ZoneWalkmapError::SizeMismatch {
205                expected,
206                actual: walkmap.len(),
207            });
208        }
209        Ok(Self {
210            grid_w,
211            grid_h,
212            grid_d,
213            walkmap,
214        })
215    }
216
217    /// Create a fully walkable (no obstacles) map.
218    pub fn all_walkable(
219        grid_w: usize,
220        grid_h: usize,
221        grid_d: usize,
222    ) -> Result<Self, ZoneWalkmapError> {
223        Self::new(vec![true; grid_w * grid_h * grid_d], grid_w, grid_h, grid_d)
224    }
225
226    /// Grid dimensions as `(w, h, d)`.
227    pub fn dimensions(&self) -> (usize, usize, usize) {
228        (self.grid_w, self.grid_h, self.grid_d)
229    }
230
231    /// Raw walkmap slice.
232    pub fn as_slice(&self) -> &[bool] {
233        &self.walkmap
234    }
235
236    /// Check if a specific voxel is walkable.
237    pub fn is_walkable_voxel(&self, vx: usize, vy: usize, vz: usize) -> bool {
238        if vx >= self.grid_w || vy >= self.grid_h || vz >= self.grid_d {
239            return false;
240        }
241        self.walkmap[vz * self.grid_h * self.grid_w + vy * self.grid_w + vx]
242    }
243
244    /// Set a specific voxel's walkability.
245    pub fn set_walkable_voxel(&mut self, vx: usize, vy: usize, vz: usize, walkable: bool) {
246        if vx < self.grid_w && vy < self.grid_h && vz < self.grid_d {
247            self.walkmap[vz * self.grid_h * self.grid_w + vy * self.grid_w + vx] = walkable;
248        }
249    }
250
251    /// Check if a continuous position is in a walkable voxel within the given zone extent.
252    fn is_walkable_pos(&self, pos: &ContinuousPos3D, extent: &ZoneExtent) -> bool {
253        let vx = ((pos.x / extent.size_x * self.grid_w as f64).floor() as usize)
254            .min(self.grid_w.saturating_sub(1));
255        let vy = ((pos.y / extent.size_y * self.grid_h as f64).floor() as usize)
256            .min(self.grid_h.saturating_sub(1));
257        let vz = ((pos.z / extent.size_z * self.grid_d as f64).floor() as usize)
258            .min(self.grid_d.saturating_sub(1));
259        self.is_walkable_voxel(vx, vy, vz)
260    }
261}
262
263// ---------------------------------------------------------------------------
264// Per-zone spatial hash (optional)
265// ---------------------------------------------------------------------------
266
267/// Uniform-grid spatial hash for fast Euclidean neighbor queries within a zone.
268///
269/// Uses the same algorithm as `ContinuousSpace3D` but scoped to a single zone.
270/// Only created for zones where [`enable_zone_spatial_hash`](HybridSpace::enable_zone_spatial_hash)
271/// is called.
272#[derive(Debug, Clone)]
273struct ZoneSpatialHash {
274    spacing: f64,
275    grid_w: usize,
276    grid_h: usize,
277    grid_d: usize,
278    cells: Vec<Vec<AgentId>>,
279}
280
281impl ZoneSpatialHash {
282    fn new(extent: &ZoneExtent, spacing: f64) -> Result<Self, HybridSpaceError> {
283        if spacing <= 0.0 {
284            return Err(HybridSpaceError::InvalidSpacing(spacing));
285        }
286        let grid_w = (extent.size_x / spacing).ceil() as usize;
287        let grid_h = (extent.size_y / spacing).ceil() as usize;
288        let grid_d = (extent.size_z / spacing).ceil() as usize;
289        Ok(Self {
290            spacing,
291            grid_w,
292            grid_h,
293            grid_d,
294            cells: vec![Vec::new(); grid_w * grid_h * grid_d],
295        })
296    }
297
298    fn pos_to_cell(&self, pos: &ContinuousPos3D) -> (usize, usize, usize) {
299        let cx = ((pos.x / self.spacing).floor() as usize).min(self.grid_w.saturating_sub(1));
300        let cy = ((pos.y / self.spacing).floor() as usize).min(self.grid_h.saturating_sub(1));
301        let cz = ((pos.z / self.spacing).floor() as usize).min(self.grid_d.saturating_sub(1));
302        (cx, cy, cz)
303    }
304
305    fn cell_index(&self, cx: usize, cy: usize, cz: usize) -> usize {
306        (cz * self.grid_h + cy) * self.grid_w + cx
307    }
308
309    fn insert(&mut self, id: AgentId, pos: &ContinuousPos3D) {
310        let (cx, cy, cz) = self.pos_to_cell(pos);
311        let idx = self.cell_index(cx, cy, cz);
312        self.cells[idx].push(id);
313    }
314
315    fn remove(&mut self, id: AgentId, pos: &ContinuousPos3D) {
316        let (cx, cy, cz) = self.pos_to_cell(pos);
317        let idx = self.cell_index(cx, cy, cz);
318        if let Some(i) = self.cells[idx].iter().position(|&a| a == id) {
319            self.cells[idx].swap_remove(i);
320        }
321    }
322
323    fn update(&mut self, id: AgentId, old_pos: &ContinuousPos3D, new_pos: &ContinuousPos3D) {
324        let old_cell = self.pos_to_cell(old_pos);
325        let new_cell = self.pos_to_cell(new_pos);
326        if old_cell != new_cell {
327            self.remove(id, old_pos);
328            self.insert(id, new_pos);
329        }
330    }
331
332    fn nearby_ids(
333        &self,
334        pos: &ContinuousPos3D,
335        radius: f64,
336        all_positions: &HashMap<AgentId, HybridPos>,
337    ) -> Vec<AgentId> {
338        let grid_r = ((radius / self.spacing).ceil() as i32) + 1;
339        let (focal_cx, focal_cy, focal_cz) = self.pos_to_cell(pos);
340        let radius_sq = radius * radius;
341        let mut ids = Vec::new();
342
343        for dz in -grid_r..=grid_r {
344            for dy in -grid_r..=grid_r {
345                for dx in -grid_r..=grid_r {
346                    let nx = focal_cx as i32 + dx;
347                    let ny = focal_cy as i32 + dy;
348                    let nz = focal_cz as i32 + dz;
349
350                    if nx >= 0
351                        && ny >= 0
352                        && nz >= 0
353                        && (nx as usize) < self.grid_w
354                        && (ny as usize) < self.grid_h
355                        && (nz as usize) < self.grid_d
356                    {
357                        let idx = self.cell_index(nx as usize, ny as usize, nz as usize);
358                        for &id in &self.cells[idx] {
359                            if let Some(agent_pos) = all_positions.get(&id) {
360                                let d = pos.distance_to(&agent_pos.offset);
361                                if d * d <= radius_sq {
362                                    ids.push(id);
363                                }
364                            }
365                        }
366                    }
367                }
368            }
369        }
370
371        ids
372    }
373}
374
375// ---------------------------------------------------------------------------
376// Errors
377// ---------------------------------------------------------------------------
378
379#[derive(Debug, Clone, Copy, PartialEq, Error)]
380pub enum HybridSpaceError {
381    #[error("invalid graph node index {0}")]
382    InvalidNode(GraphPos),
383    #[error("position is out of bounds")]
384    OutOfBounds,
385    #[error("no edge from node {from} to node {to}")]
386    NoEdge { from: GraphPos, to: GraphPos },
387    #[error("target position is blocked by an obstacle")]
388    Blocked,
389    #[error("spacing must be positive, got {0}")]
390    InvalidSpacing(f64),
391}
392
393// ---------------------------------------------------------------------------
394// HybridSpace
395// ---------------------------------------------------------------------------
396
397/// A space that layers continuous 3D zones on top of a graph topology.
398///
399/// Internally composes a [`GraphSpace`] for the graph structure (edges, BFS,
400/// directed/undirected). Each graph node carries a [`ZoneExtent`] defining
401/// its 3D bounding box, an optional [`ZoneWalkmap`] for obstacle collision,
402/// and an optional spatial hash for fast Euclidean queries within zones
403/// that contain many agents.
404///
405/// Agents can:
406/// - **Move within a zone** (continuous 3D displacement, respecting obstacles)
407/// - **Transition between zones** along graph edges
408/// - **Query neighbors** across both hops and Euclidean distance
409///
410/// Zones can optionally carry a [`ZoneWalkmap`] that marks non-walkable voxels
411/// (columns, walls, barriers). Movement functions check the walkmap and reject
412/// displacements into blocked cells.
413#[derive(Debug, Clone)]
414pub struct HybridSpace {
415    graph: GraphSpace,
416    zones: Vec<ZoneExtent>,
417    walkmaps: Vec<Option<ZoneWalkmap>>,
418    spatial_hashes: Vec<Option<ZoneSpatialHash>>,
419    /// Per-zone spatial index: agent positions tracked by zone.
420    agent_positions: HashMap<AgentId, HybridPos>,
421    /// Agent IDs stored per node (mirrors GraphSpace's internal tracking).
422    agents_at_node: Vec<Vec<AgentId>>,
423}
424
425impl HybridSpace {
426    /// Create a hybrid space with `n` zones, each having the given extents.
427    ///
428    /// The underlying graph starts with no edges -- add them with `add_edge`.
429    /// Zones start with no obstacle maps (fully walkable) and no spatial hashing.
430    pub fn new(zone_extents: Vec<ZoneExtent>) -> Self {
431        let n = zone_extents.len();
432        Self {
433            graph: GraphSpace::new(n),
434            zones: zone_extents,
435            walkmaps: vec![None; n],
436            spatial_hashes: vec![None; n],
437            agent_positions: HashMap::new(),
438            agents_at_node: vec![Vec::new(); n],
439        }
440    }
441
442    /// Create a hybrid space where all zones share the same extent.
443    pub fn uniform(n: usize, extent: ZoneExtent) -> Self {
444        Self::new(vec![extent; n])
445    }
446
447    // -- Graph delegation --
448
449    /// Number of zone nodes in the graph.
450    pub fn num_nodes(&self) -> usize {
451        self.graph.num_vertices()
452    }
453
454    /// Number of edges connecting zones.
455    pub fn num_edges(&self) -> usize {
456        self.graph.num_edges()
457    }
458
459    /// Add an undirected edge between two zone nodes.
460    ///
461    /// Returns `false` if the edge already exists or either node is invalid.
462    pub fn add_edge(&mut self, a: GraphPos, b: GraphPos) -> bool {
463        self.graph.add_edge(a, b)
464    }
465
466    /// Remove an edge between two zone nodes.
467    ///
468    /// Returns `false` if the edge did not exist.
469    pub fn rem_edge(&mut self, a: GraphPos, b: GraphPos) -> bool {
470        self.graph.rem_edge(a, b)
471    }
472
473    /// Get neighboring zone nodes according to a [`NeighborType`] selector.
474    pub fn neighbors(&self, node: GraphPos, kind: NeighborType) -> Vec<GraphPos> {
475        self.graph.neighbors(node, kind)
476    }
477
478    /// Immutable reference to the underlying graph topology.
479    pub fn graph(&self) -> &GraphSpace {
480        &self.graph
481    }
482
483    // -- Zone configuration --
484
485    /// Get the 3D extent descriptor for a zone.
486    pub fn zone_extent(&self, node: GraphPos) -> &ZoneExtent {
487        &self.zones[node]
488    }
489
490    /// Set a walkability map for a zone.
491    pub fn set_zone_walkmap(
492        &mut self,
493        node: GraphPos,
494        walkmap: ZoneWalkmap,
495    ) -> Result<(), HybridSpaceError> {
496        if node >= self.num_nodes() {
497            return Err(HybridSpaceError::InvalidNode(node));
498        }
499        self.walkmaps[node] = Some(walkmap);
500        Ok(())
501    }
502
503    /// Remove the walkability map for a zone (making it fully walkable again).
504    pub fn clear_zone_walkmap(&mut self, node: GraphPos) {
505        if node < self.num_nodes() {
506            self.walkmaps[node] = None;
507        }
508    }
509
510    /// Get the walkability map for a zone, if any.
511    pub fn zone_walkmap(&self, node: GraphPos) -> Option<&ZoneWalkmap> {
512        self.walkmaps.get(node).and_then(|w| w.as_ref())
513    }
514
515    /// Check if a continuous position within a zone is walkable.
516    ///
517    /// Returns `true` if the zone has no walkmap (fully walkable) or
518    /// if the voxel containing the position is marked walkable.
519    pub fn is_walkable(&self, node: GraphPos, pos: &ContinuousPos3D) -> bool {
520        match self.walkmaps.get(node).and_then(|w| w.as_ref()) {
521            Some(wm) => wm.is_walkable_pos(pos, &self.zones[node]),
522            None => true,
523        }
524    }
525
526    /// Enable spatial hashing for a zone to accelerate Euclidean neighbor queries.
527    pub fn enable_zone_spatial_hash(
528        &mut self,
529        node: GraphPos,
530        spacing: f64,
531    ) -> Result<(), HybridSpaceError> {
532        if node >= self.num_nodes() {
533            return Err(HybridSpaceError::InvalidNode(node));
534        }
535        let mut hash = ZoneSpatialHash::new(&self.zones[node], spacing)?;
536        for &id in &self.agents_at_node[node] {
537            if let Some(pos) = self.agent_positions.get(&id) {
538                hash.insert(id, &pos.offset);
539            }
540        }
541        self.spatial_hashes[node] = Some(hash);
542        Ok(())
543    }
544
545    /// Disable spatial hashing for a zone (reverts to linear scan).
546    pub fn disable_zone_spatial_hash(&mut self, node: GraphPos) {
547        if node < self.num_nodes() {
548            self.spatial_hashes[node] = None;
549        }
550    }
551
552    /// Whether spatial hashing is enabled for a zone.
553    pub fn has_zone_spatial_hash(&self, node: GraphPos) -> bool {
554        self.spatial_hashes.get(node).is_some_and(|h| h.is_some())
555    }
556
557    /// Add a new zone node with the given extent and return its index.
558    pub fn add_node(&mut self, extent: ZoneExtent) -> GraphPos {
559        let idx = self.graph.add_vertex();
560        self.zones.push(extent);
561        self.walkmaps.push(None);
562        self.spatial_hashes.push(None);
563        self.agents_at_node.push(Vec::new());
564        idx
565    }
566
567    // -- Agent queries --
568
569    /// Look up the current hybrid position of an agent.
570    pub fn agent_position(&self, id: AgentId) -> Option<&HybridPos> {
571        self.agent_positions.get(&id)
572    }
573
574    /// Get the agent IDs stored at a given zone node.
575    pub fn agents_at_node(&self, node: GraphPos) -> &[AgentId] {
576        &self.agents_at_node[node]
577    }
578
579    // -- Agent lifecycle --
580
581    /// Place an agent at a position in the hybrid space.
582    ///
583    /// The offset is clamped to the zone's bounds. If a walkmap is present,
584    /// the target voxel must be walkable or [`HybridSpaceError::Blocked`] is returned.
585    pub fn add_agent_internal(
586        &mut self,
587        id: AgentId,
588        pos: HybridPos,
589    ) -> Result<(), HybridSpaceError> {
590        if pos.node >= self.num_nodes() {
591            return Err(HybridSpaceError::InvalidNode(pos.node));
592        }
593        let clamped = self.zones[pos.node].clamp(&pos.offset);
594        if !self.is_walkable(pos.node, &clamped) {
595            return Err(HybridSpaceError::Blocked);
596        }
597        let final_pos = HybridPos::new(pos.node, clamped);
598        self.agent_positions.insert(id, final_pos);
599        self.agents_at_node[pos.node].push(id);
600        if let Some(hash) = &mut self.spatial_hashes[pos.node] {
601            hash.insert(id, &clamped);
602        }
603        Ok(())
604    }
605
606    fn remove_agent_internal(&mut self, id: AgentId) {
607        if let Some(pos) = self.agent_positions.remove(&id) {
608            if let Some(i) = self.agents_at_node[pos.node]
609                .iter()
610                .position(|&aid| aid == id)
611            {
612                self.agents_at_node[pos.node].swap_remove(i);
613            }
614            if let Some(hash) = &mut self.spatial_hashes[pos.node] {
615                hash.remove(id, &pos.offset);
616            }
617        }
618    }
619
620    // -- Movement --
621
622    /// Move an agent within its current zone by a 3D displacement.
623    ///
624    /// The resulting position is clamped to the zone bounds. If a walkmap
625    /// is present and the target voxel is blocked, the agent stays at its
626    /// current position and [`HybridSpaceError::Blocked`] is returned.
627    pub fn move_within_zone(
628        &mut self,
629        id: AgentId,
630        displacement: ContinuousPos3D,
631    ) -> Result<HybridPos, HybridSpaceError> {
632        let pos = self
633            .agent_positions
634            .get(&id)
635            .copied()
636            .ok_or(HybridSpaceError::OutOfBounds)?;
637
638        let new_offset = pos.offset + displacement;
639        let clamped = self.zones[pos.node].clamp(&new_offset);
640
641        if !self.is_walkable(pos.node, &clamped) {
642            return Err(HybridSpaceError::Blocked);
643        }
644
645        if let Some(hash) = &mut self.spatial_hashes[pos.node] {
646            hash.update(id, &pos.offset, &clamped);
647        }
648
649        let new_pos = HybridPos::new(pos.node, clamped);
650        self.agent_positions.insert(id, new_pos);
651        Ok(new_pos)
652    }
653
654    /// Move an agent to a specific 3D position within its current zone.
655    ///
656    /// If a walkmap is present and the target voxel is blocked, the agent
657    /// stays at its current position and [`HybridSpaceError::Blocked`] is returned.
658    pub fn move_to_offset(
659        &mut self,
660        id: AgentId,
661        offset: ContinuousPos3D,
662    ) -> Result<HybridPos, HybridSpaceError> {
663        let pos = self
664            .agent_positions
665            .get(&id)
666            .copied()
667            .ok_or(HybridSpaceError::OutOfBounds)?;
668
669        let clamped = self.zones[pos.node].clamp(&offset);
670
671        if !self.is_walkable(pos.node, &clamped) {
672            return Err(HybridSpaceError::Blocked);
673        }
674
675        if let Some(hash) = &mut self.spatial_hashes[pos.node] {
676            hash.update(id, &pos.offset, &clamped);
677        }
678
679        let new_pos = HybridPos::new(pos.node, clamped);
680        self.agent_positions.insert(id, new_pos);
681        Ok(new_pos)
682    }
683
684    /// Transition an agent to an adjacent zone along a graph edge.
685    ///
686    /// The agent is placed at `entry_offset` in the destination zone
687    /// (clamped to the zone's bounds). Returns an error if no edge
688    /// exists between the current node and `to_node`, or if the
689    /// destination voxel is blocked.
690    pub fn transition_to_zone(
691        &mut self,
692        id: AgentId,
693        to_node: GraphPos,
694        entry_offset: ContinuousPos3D,
695    ) -> Result<HybridPos, HybridSpaceError> {
696        let pos = self
697            .agent_positions
698            .get(&id)
699            .copied()
700            .ok_or(HybridSpaceError::OutOfBounds)?;
701
702        if to_node >= self.num_nodes() {
703            return Err(HybridSpaceError::InvalidNode(to_node));
704        }
705
706        // Verify edge exists
707        let out_neighbors = self.graph.neighbors_out(pos.node);
708        if !out_neighbors.contains(&to_node) {
709            return Err(HybridSpaceError::NoEdge {
710                from: pos.node,
711                to: to_node,
712            });
713        }
714
715        let clamped = self.zones[to_node].clamp(&entry_offset);
716
717        if !self.is_walkable(to_node, &clamped) {
718            return Err(HybridSpaceError::Blocked);
719        }
720
721        // Remove from old node
722        if let Some(i) = self.agents_at_node[pos.node]
723            .iter()
724            .position(|&aid| aid == id)
725        {
726            self.agents_at_node[pos.node].swap_remove(i);
727        }
728        if let Some(hash) = &mut self.spatial_hashes[pos.node] {
729            hash.remove(id, &pos.offset);
730        }
731
732        // Place in new node
733        let new_pos = HybridPos::new(to_node, clamped);
734        self.agent_positions.insert(id, new_pos);
735        self.agents_at_node[to_node].push(id);
736        if let Some(hash) = &mut self.spatial_hashes[to_node] {
737            hash.insert(id, &clamped);
738        }
739        Ok(new_pos)
740    }
741
742    /// Transition to an adjacent zone, placing the agent at the center of the
743    /// destination zone.
744    pub fn transition_to_zone_center(
745        &mut self,
746        id: AgentId,
747        to_node: GraphPos,
748    ) -> Result<HybridPos, HybridSpaceError> {
749        let center = self
750            .zones
751            .get(to_node)
752            .ok_or(HybridSpaceError::InvalidNode(to_node))?
753            .center();
754        self.transition_to_zone(id, to_node, center)
755    }
756
757    // -- Neighbor queries --
758
759    /// Find all agent IDs within `graph_radius` hops AND within `euclidean_radius`
760    /// distance of the query position within each reached zone.
761    ///
762    /// Semantics:
763    /// - for the origin zone, this performs a true Euclidean query
764    /// - for reached non-origin zones, this returns all agents in those zones
765    /// - no synthetic cross-zone Euclidean metric is imposed by the engine
766    ///
767    /// Use [`nearby_ids_same_zone`](Self::nearby_ids_same_zone) for geometric
768    /// proximity and [`nearby_ids_by_hops`](Self::nearby_ids_by_hops) for purely
769    /// topological reachability.
770    pub fn nearby_ids(
771        &self,
772        pos: &HybridPos,
773        graph_radius: usize,
774        euclidean_radius: f64,
775    ) -> Vec<AgentId> {
776        let mut result = Vec::new();
777        let mut visited = HashSet::new();
778        let mut queue = VecDeque::new();
779
780        visited.insert(pos.node);
781        queue.push_back((pos.node, 0usize));
782
783        while let Some((node, depth)) = queue.pop_front() {
784            if node == pos.node {
785                // Same zone: use spatial hash if available, otherwise linear scan
786                self.collect_nearby_same_zone(pos, euclidean_radius, &mut result);
787            } else {
788                // Different zone: include all agents (cross-zone Euclidean
789                // filtering is not meaningful without global coordinates).
790                result.extend_from_slice(&self.agents_at_node[node]);
791            }
792
793            // BFS expansion
794            if depth < graph_radius {
795                for &neighbor in self.graph.neighbors_out(node) {
796                    if visited.insert(neighbor) {
797                        queue.push_back((neighbor, depth + 1));
798                    }
799                }
800            }
801        }
802
803        result
804    }
805
806    /// All agent IDs within `graph_radius` hops (ignoring Euclidean distance).
807    pub fn nearby_ids_by_hops(&self, node: GraphPos, graph_radius: usize) -> Vec<AgentId> {
808        let mut result = Vec::new();
809        let mut visited = HashSet::new();
810        let mut queue = VecDeque::new();
811
812        visited.insert(node);
813        queue.push_back((node, 0usize));
814
815        while let Some((n, depth)) = queue.pop_front() {
816            result.extend_from_slice(&self.agents_at_node[n]);
817            if depth < graph_radius {
818                for &neighbor in self.graph.neighbors_out(n) {
819                    if visited.insert(neighbor) {
820                        queue.push_back((neighbor, depth + 1));
821                    }
822                }
823            }
824        }
825
826        result
827    }
828
829    /// All agent IDs in the same zone within Euclidean distance.
830    ///
831    /// Uses spatial hashing if enabled for the zone (O(k)), otherwise
832    /// falls back to linear scan over all agents in the zone (O(n)).
833    pub fn nearby_ids_same_zone(&self, pos: &HybridPos, radius: f64) -> Vec<AgentId> {
834        let mut result = Vec::new();
835        self.collect_nearby_same_zone(pos, radius, &mut result);
836        result
837    }
838
839    /// Internal: collect nearby agents in the same zone into `result`.
840    fn collect_nearby_same_zone(&self, pos: &HybridPos, radius: f64, result: &mut Vec<AgentId>) {
841        if let Some(hash) = self.spatial_hashes.get(pos.node).and_then(|h| h.as_ref()) {
842            // O(k) spatial hash query
843            let ids = hash.nearby_ids(&pos.offset, radius, &self.agent_positions);
844            result.extend(ids);
845        } else {
846            // O(n) linear scan fallback
847            for &aid in &self.agents_at_node[pos.node] {
848                if let Some(agent_pos) = self.agent_positions.get(&aid) {
849                    if pos.offset.distance_to(&agent_pos.offset) <= radius {
850                        result.push(aid);
851                    }
852                }
853            }
854        }
855    }
856}
857
858impl Space for HybridSpace {}
859
860impl<A> SpaceInteraction<A> for HybridSpace
861where
862    A: PositionedAgent<Position = HybridPos>,
863{
864    type Error = HybridSpaceError;
865
866    fn random_position<R: rand::RngCore>(&self, rng: &mut R) -> A::Position {
867        // Try to find a walkable random position (up to 100 attempts)
868        for _ in 0..100 {
869            let node = rng.gen_range(0..self.num_nodes());
870            let zone = &self.zones[node];
871            let offset = ContinuousPos3D::new(
872                rng.gen_range(0.0..zone.size_x),
873                rng.gen_range(0.0..zone.size_y),
874                rng.gen_range(0.0..zone.size_z),
875            );
876            if self.is_walkable(node, &offset) {
877                return HybridPos::new(node, offset);
878            }
879        }
880        // Fallback: return any position (may be blocked)
881        let node = rng.gen_range(0..self.num_nodes());
882        let zone = &self.zones[node];
883        let offset = ContinuousPos3D::new(
884            rng.gen_range(0.0..zone.size_x),
885            rng.gen_range(0.0..zone.size_y),
886            rng.gen_range(0.0..zone.size_z),
887        );
888        HybridPos::new(node, offset)
889    }
890
891    fn add_agent(&mut self, agent: &A) -> Result<(), Self::Error> {
892        self.add_agent_internal(agent.id(), *agent.position())
893    }
894
895    fn remove_agent(&mut self, agent: &A) -> Result<(), Self::Error> {
896        self.remove_agent_internal(agent.id());
897        Ok(())
898    }
899
900    fn nearby_ids(&self, position: &A::Position, radius: usize) -> Vec<AgentId> {
901        // When called via SpaceInteraction, treat radius as graph hops and
902        // include all agents at reachable nodes.
903        self.nearby_ids_by_hops(position.node, radius)
904    }
905}