cs2_nav/
nav.rs

1/// Module for navigation capabilities aimed at CS2.
2///
3/// Core taken from: <https://github.com/pnxenopoulos/awpy/blob/main/awpy/nav.py>
4use crate::collisions::CollisionChecker;
5use crate::constants::{
6    CROUCHING_ATTRIBUTE_FLAG, CROUCHING_SPEED, FOOTSTEP_RANGE, JUMP_HEIGHT, LADDER_SPEED,
7    PLAYER_CROUCH_HEIGHT, PLAYER_EYE_LEVEL, PLAYER_HEIGHT, PLAYER_WIDTH, RUNNING_SPEED,
8};
9use crate::position::{Position, inverse_distance_weighting};
10use crate::utils::create_file_with_parents;
11
12use bincode::{deserialize_from, serialize_into};
13use byteorder::{LittleEndian, ReadBytesExt};
14use geo::algorithm::line_measures::metric_spaces::Euclidean;
15use geo::geometry::{LineString, Point, Polygon};
16use geo::{Centroid, Contains, Distance, Intersects};
17use itertools::{Itertools, iproduct};
18use petgraph::algo::astar;
19use petgraph::graphmap::DiGraphMap;
20use petgraph::visit::EdgeRef;
21use pyo3::exceptions::{PyException, PyFileNotFoundError};
22use pyo3::{
23    FromPyObject, IntoPyObject, PyErr, PyResult, create_exception, pyclass, pyfunction, pymethods,
24};
25use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
26use rustc_hash::FxHashMap as HashMap;
27use rustc_hash::FxHashSet as HashSet;
28use serde::de::{self, MapAccess, Visitor};
29use serde::{Deserialize, Deserializer, Serialize};
30use simple_tqdm::{Config, ParTqdm, Tqdm};
31use std::cmp::Ordering;
32use std::f64;
33use std::fmt;
34use std::fs::File;
35use std::hash::Hash;
36use std::io::{BufReader, Read};
37use std::path::{Path, PathBuf};
38
39// --- DynamicAttributeFlags ---
40#[pyclass(eq, module = "cs2_nav")]
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
42pub struct DynamicAttributeFlags(i64);
43
44#[pymethods]
45impl DynamicAttributeFlags {
46    #[must_use]
47    #[new]
48    pub const fn new(value: i64) -> Self {
49        Self(value)
50    }
51}
52
53impl From<DynamicAttributeFlags> for i64 {
54    fn from(flag: DynamicAttributeFlags) -> Self {
55        flag.0
56    }
57}
58
59pub trait AreaLike {
60    fn centroid(&self) -> Position;
61    fn requires_crouch(&self) -> bool;
62    fn area_id(&self) -> u32;
63}
64
65struct NavMeshConnection {
66    area_id: u32,
67    #[allow(dead_code)]
68    edge_id: u32,
69}
70
71impl NavMeshConnection {
72    fn from_binary(reader: &mut BufReader<File>) -> Result<Self, PyErr> {
73        let area_id = reader
74            .read_u32::<LittleEndian>()
75            .map_err(|_| InvalidNavFileError::new_err("Failed to read connection area id."))?;
76        let edge_id = reader
77            .read_u32::<LittleEndian>()
78            .map_err(|_| InvalidNavFileError::new_err("Failed to read connection edge id."))?;
79        Ok(Self { area_id, edge_id })
80    }
81}
82
83#[pyclass(eq, str, module = "cs2_nav")]
84/// A navigation area in the map.
85#[derive(Debug, Clone, Serialize)]
86pub struct NavArea {
87    /// Unique ID of the area.
88    ///
89    /// Only unique for a given mesh
90    #[pyo3(get)]
91    pub area_id: u32,
92    #[pyo3(get)]
93    pub hull_index: u32,
94    #[pyo3(get)]
95    pub dynamic_attribute_flags: DynamicAttributeFlags,
96    /// Corners of the polygon making up the area.
97    #[pyo3(get)]
98    pub corners: Vec<Position>,
99    /// IDs of areas this one is connected to.
100    ///
101    /// Connections are not necessarily symmetric.
102    #[pyo3(get)]
103    pub connections: Vec<u32>,
104    /// IDs of ladders above this area.
105    #[pyo3(get)]
106    pub ladders_above: Vec<u32>,
107    /// IDs of ladders below this area.
108    #[pyo3(get)]
109    pub ladders_below: Vec<u32>,
110    /// Precomputed centroid of the area.
111    #[pyo3(get)]
112    centroid: Position,
113}
114
115impl fmt::Display for NavArea {
116    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
117        write!(
118            f,
119            "NavArea(area_id: {}, hull_index: {}, dynamic_attribute_flags: {:?}, corners: {:?}, connections: {:?}, ladders_above: {:?}, ladders_below: {:?})",
120            self.area_id,
121            self.hull_index,
122            self.dynamic_attribute_flags,
123            self.corners,
124            self.connections,
125            self.ladders_above,
126            self.ladders_below
127        )
128    }
129}
130
131/// Equality is purely done through the `area_id`.
132impl PartialEq for NavArea {
133    fn eq(&self, other: &Self) -> bool {
134        self.area_id == other.area_id
135    }
136}
137
138#[allow(clippy::cast_precision_loss)]
139/// Computes the centroid of the polygon (averaging all corners).
140#[must_use]
141pub fn centroid(corners: &[Position]) -> Position {
142    if corners.is_empty() {
143        return Position::new(0.0, 0.0, 0.0);
144    }
145    let (sum_x, sum_y, sum_z) = corners.iter().fold((0.0, 0.0, 0.0), |(sx, sy, sz), c| {
146        (sx + c.x, sy + c.y, sz + c.z)
147    });
148    let count = corners.len() as f64;
149    Position::new(sum_x / count, sum_y / count, sum_z / count)
150}
151
152impl NavArea {
153    /// Returns a 2D Shapely Polygon using the (x,y) of the corners.
154    #[must_use]
155    pub fn to_polygon_2d(&self) -> Polygon {
156        let coords: Vec<(f64, f64)> = self.corners.iter().map(|c| (c.x, c.y)).collect();
157        Polygon::new(LineString::from(coords), vec![])
158    }
159
160    fn read_connections(reader: &mut BufReader<File>) -> Result<Vec<NavMeshConnection>, PyErr> {
161        let connection_count = reader
162            .read_u32::<LittleEndian>()
163            .map_err(|_| InvalidNavFileError::new_err("Failed to read connection count."))?;
164        let mut connections = Vec::with_capacity(connection_count as usize);
165        for _ in 0..connection_count {
166            connections.push(NavMeshConnection::from_binary(reader)?);
167        }
168        Ok(connections)
169    }
170
171    fn from_data(
172        reader: &mut BufReader<File>,
173        nav_mesh_version: u32,
174        polygons: Option<&Vec<Vec<Position>>>,
175    ) -> Result<Self, PyErr> {
176        let area_id = reader
177            .read_u32::<LittleEndian>()
178            .map_err(|_| InvalidNavFileError::new_err("Failed to read area id."))?;
179
180        let dynamic_attribute_flags =
181            DynamicAttributeFlags::new(reader.read_i64::<LittleEndian>().map_err(|_| {
182                InvalidNavFileError::new_err("Failed to read dynamic attribute flags.")
183            })?);
184
185        let hull_index = u32::from(
186            reader
187                .read_u8()
188                .map_err(|_| InvalidNavFileError::new_err("Failed to read hull index."))?,
189        );
190
191        let corners =
192            if nav_mesh_version >= 31 && polygons.is_some() {
193                let polygon_index = reader
194                    .read_u32::<LittleEndian>()
195                    .map_err(|_| InvalidNavFileError::new_err("Failed to read polygon index."))?
196                    as usize;
197                polygons.as_ref().unwrap()[polygon_index].clone()
198            } else {
199                let corner_count = reader
200                    .read_u32::<LittleEndian>()
201                    .map_err(|_| InvalidNavFileError::new_err("Failed to read corner count."))?;
202                let mut corners = Vec::with_capacity(corner_count as usize);
203                for _ in 0..corner_count {
204                    let x =
205                        f64::from(reader.read_f32::<LittleEndian>().map_err(|_| {
206                            InvalidNavFileError::new_err("Failed to read corner x.")
207                        })?);
208                    let y =
209                        f64::from(reader.read_f32::<LittleEndian>().map_err(|_| {
210                            InvalidNavFileError::new_err("Failed to read corner y.")
211                        })?);
212                    let z =
213                        f64::from(reader.read_f32::<LittleEndian>().map_err(|_| {
214                            InvalidNavFileError::new_err("Failed to read corner z.")
215                        })?);
216                    corners.push(Position { x, y, z });
217                }
218                corners
219            };
220
221        reader
222            .read_u32::<LittleEndian>()
223            .map_err(|_| InvalidNavFileError::new_err("Failed to skip."))?; // Skip almost always 0
224
225        let mut connections = Vec::new();
226        for _ in 0..corners.len() {
227            for conn in Self::read_connections(reader)? {
228                connections.push(conn.area_id);
229            }
230        }
231
232        reader
233            .read_exact(&mut [0u8; 5])
234            .map_err(|_| InvalidNavFileError::new_err("Failed to skip."))?; // Skip legacy hiding and encounter data counts
235
236        let ladder_above_count = reader
237            .read_u32::<LittleEndian>()
238            .map_err(|_| InvalidNavFileError::new_err("Failed to read ladder above count."))?;
239        let mut ladders_above = Vec::with_capacity(ladder_above_count as usize);
240        for _ in 0..ladder_above_count {
241            ladders_above.push(
242                reader
243                    .read_u32::<LittleEndian>()
244                    .map_err(|_| InvalidNavFileError::new_err("Failed to read ladder above."))?,
245            );
246        }
247
248        let ladder_below_count = reader
249            .read_u32::<LittleEndian>()
250            .map_err(|_| InvalidNavFileError::new_err("Failed to read ladder below count."))?;
251        let mut ladders_below = Vec::with_capacity(ladder_below_count as usize);
252        for _ in 0..ladder_below_count {
253            ladders_below.push(
254                reader
255                    .read_u32::<LittleEndian>()
256                    .map_err(|_| InvalidNavFileError::new_err("Failed to read ladder below."))?,
257            );
258        }
259
260        Ok(Self::new(
261            area_id,
262            hull_index,
263            dynamic_attribute_flags,
264            corners,
265            connections,
266            ladders_above,
267            ladders_below,
268        ))
269    }
270}
271
272#[pymethods]
273impl NavArea {
274    #[must_use]
275    #[new]
276    pub fn new(
277        area_id: u32,
278        hull_index: u32,
279        dynamic_attribute_flags: DynamicAttributeFlags,
280        corners: Vec<Position>,
281        connections: Vec<u32>,
282        ladders_above: Vec<u32>,
283        ladders_below: Vec<u32>,
284    ) -> Self {
285        let centroid = centroid(&corners);
286        Self {
287            area_id,
288            hull_index,
289            dynamic_attribute_flags,
290            corners,
291            connections,
292            ladders_above,
293            ladders_below,
294            centroid,
295        }
296    }
297
298    /// Compute the 2D polygon area (ignoring z) using the shoelace formula.
299    #[must_use]
300    #[getter]
301    pub fn size(&self) -> f64 {
302        if self.corners.len() < 3 {
303            return 0.0;
304        }
305        let mut x: Vec<f64> = self.corners.iter().map(|c| c.x).collect();
306        let mut y: Vec<f64> = self.corners.iter().map(|c| c.y).collect();
307        // close polygon loop
308        x.push(x[0]);
309        y.push(y[0]);
310
311        let mut area = 0.0;
312        for i in 0..self.corners.len() {
313            area += x[i].mul_add(y[i + 1], -(y[i] * x[i + 1]));
314        }
315        area.abs() / 2.0
316    }
317
318    /// Checks if a point is inside the area by converting to 2D.
319    #[must_use]
320    pub fn contains(&self, point: &Position) -> bool {
321        self.to_polygon_2d().contains(&point.to_point_2d())
322    }
323
324    #[must_use]
325    pub fn centroid_distance(&self, point: &Position) -> f64 {
326        self.centroid().distance(point)
327    }
328}
329
330/// Custom deserialization for `NavArea`
331///
332/// Can handle a lack of the centroid and calculates it on `NavArea` creation.
333impl<'de> Deserialize<'de> for NavArea {
334    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
335    where
336        D: Deserializer<'de>,
337    {
338        struct NavAreaVisitor;
339
340        impl<'de> Visitor<'de> for NavAreaVisitor {
341            type Value = NavArea;
342
343            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
344                formatter.write_str("a NavArea struct")
345            }
346
347            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
348            where
349                A: MapAccess<'de>,
350            {
351                let mut area_id = None;
352                let mut hull_index = None;
353                let mut dynamic_attribute_flags = None;
354                let mut corners: Option<Vec<Position>> = None;
355                let mut connections = None;
356                let mut ladders_above = None;
357                let mut ladders_below = None;
358                let mut nav_centroid = None;
359
360                while let Some(key) = map.next_key::<String>()? {
361                    match key.as_str() {
362                        "area_id" => area_id = Some(map.next_value()?),
363                        "hull_index" => hull_index = Some(map.next_value()?),
364                        "dynamic_attribute_flags" => {
365                            dynamic_attribute_flags = Some(map.next_value()?);
366                        }
367                        "corners" => corners = Some(map.next_value()?),
368                        "connections" => connections = Some(map.next_value()?),
369                        "ladders_above" => ladders_above = Some(map.next_value()?),
370                        "ladders_below" => ladders_below = Some(map.next_value()?),
371                        "centroid" => nav_centroid = Some(map.next_value()?),
372                        _ => {
373                            let _: serde::de::IgnoredAny = map.next_value()?;
374                        }
375                    }
376                }
377
378                let area_id = area_id.ok_or_else(|| de::Error::missing_field("area_id"))?;
379                let hull_index = hull_index.unwrap_or(0); // Default value
380                let dynamic_attribute_flags = dynamic_attribute_flags
381                    .ok_or_else(|| de::Error::missing_field("dynamic_attribute_flags"))?;
382                let corners = corners.ok_or_else(|| de::Error::missing_field("corners"))?;
383                let connections = connections.unwrap_or_default(); // Default value
384                let ladders_above = ladders_above.unwrap_or_default(); // Default value
385                let ladders_below = ladders_below.unwrap_or_default(); // Default value
386                let nav_centroid = nav_centroid.unwrap_or_else(|| centroid(&corners)); // Calculate centroid if missing
387
388                Ok(NavArea {
389                    area_id,
390                    hull_index,
391                    dynamic_attribute_flags,
392                    corners,
393                    connections,
394                    ladders_above,
395                    ladders_below,
396                    centroid: nav_centroid,
397                })
398            }
399        }
400
401        deserializer.deserialize_struct(
402            "NavArea",
403            &[
404                "area_id",
405                "hull_index",
406                "dynamic_attribute_flags",
407                "corners",
408                "connections",
409                "ladders_above",
410                "ladders_below",
411                "centroid",
412            ],
413            NavAreaVisitor,
414        )
415    }
416}
417
418impl AreaLike for NavArea {
419    fn centroid(&self) -> Position {
420        self.centroid
421    }
422    fn requires_crouch(&self) -> bool {
423        self.dynamic_attribute_flags == CROUCHING_ATTRIBUTE_FLAG
424    }
425
426    fn area_id(&self) -> u32 {
427        self.area_id
428    }
429}
430
431impl From<NewNavArea> for NavArea {
432    fn from(item: NewNavArea) -> Self {
433        Self {
434            area_id: item.area_id,
435            hull_index: 0,
436            dynamic_attribute_flags: item.dynamic_attribute_flags,
437            corners: item.corners,
438            connections: Vec::from_iter(item.connections),
439            ladders_above: Vec::from_iter(item.ladders_above),
440            ladders_below: Vec::from_iter(item.ladders_below),
441            centroid: item.centroid,
442        }
443    }
444}
445
446/// Result of a pathfinding operation.
447///
448/// Contains the path as a list of `NavArea` objects and the total distance.
449#[pyclass(eq, module = "cs2_nav")]
450#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
451pub struct PathResult {
452    #[pyo3(get, set)]
453    pub path: Vec<NavArea>,
454    #[pyo3(get, set)]
455    pub distance: f64,
456}
457
458#[pymethods]
459impl PathResult {
460    #[must_use]
461    #[new]
462    pub const fn new(path: Vec<NavArea>, distance: f64) -> Self {
463        Self { path, distance }
464    }
465}
466
467/// Enum for path finding input.
468///
469/// Can either be the ID of an area or a position.
470#[derive(Debug, Clone, Deserialize, Serialize, FromPyObject)]
471pub enum AreaIdent {
472    #[pyo3(transparent, annotation = "int")]
473    Id(u32),
474    #[pyo3(transparent, annotation = "Position")]
475    Pos(Position),
476}
477
478#[derive(Debug, Clone, Deserialize, Serialize)]
479struct NavSerializationHelperStruct {
480    pub version: u32,
481    pub sub_version: u32,
482    pub is_analyzed: bool,
483    pub areas: HashMap<u32, NavArea>,
484}
485
486#[pyclass(eq, str, module = "cs2_nav")]
487#[derive(Debug, Clone)]
488pub struct Nav {
489    #[pyo3(get)]
490    pub version: u32,
491    #[pyo3(get)]
492    pub sub_version: u32,
493    #[pyo3(get)]
494    pub areas: HashMap<u32, NavArea>,
495    #[pyo3(get)]
496    pub is_analyzed: bool,
497    pub graph: DiGraphMap<u32, f64>,
498}
499
500impl fmt::Display for Nav {
501    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
502        write!(
503            f,
504            "Nav(version: {}, sub_version: {}, areas_count: {}, is_analyzed: {})",
505            self.version,
506            self.sub_version,
507            self.areas.len(), // Show the number of entries in the areas map
508            self.is_analyzed
509        )
510    }
511}
512
513impl PartialEq for Nav {
514    fn eq(&self, other: &Self) -> bool {
515        self.version == other.version
516            && self.sub_version == other.sub_version
517            && self.areas == other.areas
518            && self.is_analyzed == other.is_analyzed
519    }
520}
521
522impl Nav {
523    pub const MAGIC: u32 = 0xFEED_FACE;
524
525    /// Find the area that contains the position and has the closest centroid by z.
526    ///
527    /// If no area contains the position, then `None` is returned.
528    ///
529    /// # Panics
530    ///
531    /// Will panic if the comparison of the position centroid z values against any area centroid z values returns `None`.
532    #[must_use]
533    pub fn find_area(&self, position: &Position) -> Option<&NavArea> {
534        self.areas
535            .values()
536            .filter(|area| area.contains(position))
537            .min_by(|a, b| {
538                ((a.centroid().z - position.z).abs() - (b.centroid().z - position.z).abs())
539                    .partial_cmp(&0.0)
540                    .unwrap()
541            })
542    }
543
544    /// Find the area with the closest centroid to the position.
545    ///
546    /// # Panics
547    ///
548    /// Will panic if the comparison of the positions centroid distance against any area centroid distance returns `None`.
549    #[must_use]
550    pub fn find_closest_area_centroid(&self, position: &Position) -> &NavArea {
551        self.areas
552            .values()
553            .min_by(|a, b| {
554                a.centroid_distance(position)
555                    .partial_cmp(&b.centroid_distance(position))
556                    .unwrap()
557            })
558            .unwrap()
559    }
560
561    /// Utility heuristic function for A* using Euclidean distance between node centroids.
562    fn dist_heuristic(&self, node_a: u32, node_b: u32) -> f64 {
563        let a = &self.areas[&node_a].centroid();
564        let b = &self.areas[&node_b].centroid();
565        a.distance_2d(b)
566    }
567
568    /// Utility function to calculate the cost of a path(segment).
569    fn path_cost(&self, path: &[u32]) -> f64 {
570        path.iter()
571            .tuple_windows()
572            .map(|(u, v)| self.graph.edge_weight(*u, *v).unwrap())
573            .sum()
574    }
575
576    /// Save the navigation mesh to a JSON file.
577    ///
578    /// # Panics
579    ///
580    /// Will panic if the file cannot be created or written to.
581    pub fn save_to_json(&self, filename: &Path) {
582        let mut file = create_file_with_parents(filename);
583        let helper = NavSerializationHelperStruct {
584            version: self.version,
585            sub_version: self.sub_version,
586            is_analyzed: self.is_analyzed,
587            areas: self.areas.clone(),
588        };
589        serde_json::to_writer(&mut file, &helper).unwrap();
590    }
591
592    /// Load a struct instance from a JSON file
593    ///
594    /// # Panics
595    ///
596    /// Will panic if the file cannot be opened or read from.
597    #[must_use]
598    pub fn from_json(filename: &Path) -> Self {
599        let file = File::open(filename).unwrap();
600        let helper: NavSerializationHelperStruct = serde_json::from_reader(file).unwrap();
601        Self::new(
602            helper.version,
603            helper.sub_version,
604            helper.areas,
605            helper.is_analyzed,
606        )
607    }
608
609    fn read_polygons(
610        reader: &mut BufReader<File>,
611        version: u32,
612    ) -> Result<Vec<Vec<Position>>, PyErr> {
613        let corner_count = reader
614            .read_u32::<LittleEndian>()
615            .map_err(|_| InvalidNavFileError::new_err("Could not read corner count."))?;
616
617        let mut corners = Vec::with_capacity(corner_count as usize);
618
619        for _ in 0..corner_count {
620            let x = f64::from(
621                reader
622                    .read_f32::<LittleEndian>()
623                    .map_err(|_| InvalidNavFileError::new_err("Could not read corner x."))?,
624            );
625            let y = f64::from(
626                reader
627                    .read_f32::<LittleEndian>()
628                    .map_err(|_| InvalidNavFileError::new_err("Could not read corner y."))?,
629            );
630            let z = f64::from(
631                reader
632                    .read_f32::<LittleEndian>()
633                    .map_err(|_| InvalidNavFileError::new_err("Could not read corner z."))?,
634            );
635            corners.push(Position { x, y, z });
636        }
637
638        let polygon_count = reader
639            .read_u32::<LittleEndian>()
640            .map_err(|_| InvalidNavFileError::new_err("Could not read polygon count."))?;
641
642        let mut polygons = Vec::with_capacity(polygon_count as usize);
643        for _ in 0..polygon_count {
644            polygons.push(Self::read_polygon(reader, &corners, version)?);
645        }
646
647        Ok(polygons)
648    }
649
650    fn read_polygon(
651        reader: &mut BufReader<File>,
652        corners: &[Position],
653        version: u32,
654    ) -> Result<Vec<Position>, PyErr> {
655        let corner_count = reader
656            .read_u8()
657            .map_err(|_| InvalidNavFileError::new_err("Could not read polygon corner count."))?
658            as usize;
659        let mut polygon = Vec::with_capacity(corner_count);
660        for _ in 0..corner_count {
661            let index = reader
662                .read_u32::<LittleEndian>()
663                .map_err(|_| InvalidNavFileError::new_err("Could not read polygon corner index."))?
664                as usize;
665            polygon.push(corners[index]);
666        }
667        if version >= 35 {
668            reader
669                .read_u32::<LittleEndian>()
670                .map_err(|_| InvalidNavFileError::new_err("Failed to skip unk."))?; // Skip unk
671        }
672        Ok(polygon)
673    }
674
675    fn read_areas(
676        reader: &mut BufReader<File>,
677        polygons: Option<&Vec<Vec<Position>>>,
678        version: u32,
679    ) -> Result<HashMap<u32, NavArea>, PyErr> {
680        let area_count = reader
681            .read_u32::<LittleEndian>()
682            .map_err(|_| InvalidNavFileError::new_err("Failed to read area count."))?;
683        let mut areas = HashMap::default();
684        for _ in 0..area_count {
685            let area = NavArea::from_data(reader, version, polygons)?;
686            areas.insert(area.area_id, area);
687        }
688        Ok(areas)
689    }
690}
691
692fn has_overlap<T: PartialEq>(a: &[T], b: &[T]) -> bool {
693    a.iter().any(|x| b.contains(x))
694}
695
696create_exception!(cs2_nav, InvalidNavFileError, PyException);
697
698#[pymethods]
699impl Nav {
700    #[must_use]
701    #[new]
702    pub fn new(
703        version: u32,
704        sub_version: u32,
705        areas: HashMap<u32, NavArea>,
706        is_analyzed: bool,
707    ) -> Self {
708        let mut graph = DiGraphMap::new();
709
710        // Add nodes
711        for area_id in areas.keys() {
712            graph.add_node(*area_id);
713        }
714
715        // Add edges
716        for (area_id, area) in &areas {
717            for connected_area_id in &area.connections {
718                let connected_area = &areas[connected_area_id];
719                let dx = area.centroid().x - connected_area.centroid().x;
720                let dy = area.centroid().y - connected_area.centroid().y;
721                let dist_weight = dx.hypot(dy);
722
723                let area_speed = if area.requires_crouch() {
724                    CROUCHING_SPEED
725                } else {
726                    RUNNING_SPEED
727                };
728
729                let connected_area_speed = if connected_area.requires_crouch() {
730                    CROUCHING_SPEED
731                } else {
732                    RUNNING_SPEED
733                };
734
735                let area_time_adjusted_distance = dist_weight * (RUNNING_SPEED / area_speed);
736                let connected_area_time_adjusted_distance =
737                    dist_weight * (RUNNING_SPEED / connected_area_speed);
738
739                // Only do this from bottom of the ladder to the top.
740                // For the downwards way we can just drop off and keep our horizontal speed.
741                let connected_by_ladder =
742                    has_overlap(&area.ladders_above, &connected_area.ladders_below);
743                let ladder_distance = if connected_by_ladder {
744                    let dz = connected_area.centroid().z - area.centroid().z - JUMP_HEIGHT;
745                    dz.max(0_f64)
746                } else {
747                    0.0
748                };
749                let time_adjusted_ladder_distance =
750                    ladder_distance * (RUNNING_SPEED / LADDER_SPEED);
751
752                let time_adjusted =
753                    ((area_time_adjusted_distance + connected_area_time_adjusted_distance) / 2.0)
754                        + time_adjusted_ladder_distance;
755
756                graph.add_edge(*area_id, *connected_area_id, time_adjusted);
757            }
758        }
759
760        Self {
761            version,
762            sub_version,
763            areas,
764            is_analyzed,
765            graph,
766        }
767    }
768
769    /// Finds the path between two areas or positions.
770    #[must_use]
771    pub fn find_path(&self, start: AreaIdent, end: AreaIdent) -> PathResult {
772        // Find the start areas for path finding.
773        let start_area = match start {
774            AreaIdent::Pos(pos) => {
775                self.find_area(&pos)
776                    .unwrap_or_else(|| self.find_closest_area_centroid(&pos))
777                    .area_id
778            }
779            AreaIdent::Id(id) => id,
780        };
781
782        let end_area = match end {
783            AreaIdent::Pos(pos) => {
784                self.find_area(&pos)
785                    .unwrap_or_else(|| self.find_closest_area_centroid(&pos))
786                    .area_id
787            }
788
789            AreaIdent::Id(id) => id,
790        };
791
792        // Perform A* path finding.
793        let Some((distance, path_ids)) = astar(
794            &self.graph,
795            start_area,
796            |finish| finish == end_area,
797            |e| *e.weight(),
798            |node| self.dist_heuristic(node, end_area),
799        ) else {
800            return PathResult {
801                path: Vec::new(),
802                distance: f64::MAX,
803            };
804        };
805
806        // Calculate the total distance.
807        // Idea is so take the distance from a starting position to the SECOND node in the path.
808        let total_distance = if path_ids.len() <= 2 {
809            match (start, end) {
810                (AreaIdent::Pos(start_pos), AreaIdent::Pos(end_pos)) => {
811                    start_pos.distance_2d(&end_pos)
812                }
813                (AreaIdent::Id(_), AreaIdent::Id(_)) => distance,
814                // When one of them is a vector, assume using Euclidean distance to/from centroid.
815                (AreaIdent::Pos(start_pos), AreaIdent::Id(_)) => {
816                    start_pos.distance_2d(&self.areas[&end_area].centroid())
817                }
818                (AreaIdent::Id(_), AreaIdent::Pos(end_pos)) => {
819                    self.areas[&start_area].centroid().distance_2d(&end_pos)
820                }
821            }
822        } else {
823            // Use windows for middle path distances.
824            let start_distance = match start {
825                AreaIdent::Pos(start_pos) => {
826                    start_pos.distance_2d(&self.areas[&path_ids[1]].centroid())
827                }
828                AreaIdent::Id(_) => self.path_cost(&path_ids[0..=1]),
829            };
830
831            let middle_distance: f64 = self.path_cost(&path_ids[1..path_ids.len() - 1]);
832
833            let end_distance = match end {
834                AreaIdent::Pos(end_pos) => self.areas[&path_ids[path_ids.len() - 2]]
835                    .centroid()
836                    .distance_2d(&end_pos),
837                AreaIdent::Id(_) => {
838                    self.path_cost(&path_ids[path_ids.len() - 2..path_ids.len() - 1])
839                }
840            };
841
842            start_distance + middle_distance + end_distance
843        };
844
845        // Convert the path_ids to NavArea objects.
846        let path = path_ids
847            .iter()
848            .filter_map(|id| self.areas.get(id).cloned())
849            .collect();
850
851        PathResult {
852            path,
853            distance: total_distance,
854        }
855    }
856
857    /// Find the area that contains the position and has the closest centroid by z.
858    ///
859    /// If no area contains the position, then `None` is returned.
860    #[must_use]
861    #[pyo3(name = "find_area")]
862    pub fn find_area_py(&self, position: &Position) -> Option<NavArea> {
863        self.find_area(position).cloned()
864    }
865
866    /// Find the area with the closest centroid to the position.
867    #[must_use]
868    #[pyo3(name = "find_closest_area_centroid")]
869    pub fn find_closest_area_centroid_py(&self, position: &Position) -> NavArea {
870        self.find_closest_area_centroid(position).clone()
871    }
872
873    /// Save the navigation mesh to a JSON file.
874    #[pyo3(name = "to_json")]
875    #[allow(clippy::needless_pass_by_value)]
876    pub fn save_to_json_py(&self, path: PathBuf) {
877        self.save_to_json(&path);
878    }
879
880    /// Load a struct instance from a JSON file
881    #[must_use]
882    #[pyo3(name = "from_json")]
883    #[allow(clippy::needless_pass_by_value)]
884    #[staticmethod]
885    pub fn from_json_py(path: PathBuf) -> Self {
886        Self::from_json(&path)
887    }
888
889    #[allow(clippy::needless_pass_by_value)]
890    #[staticmethod]
891    fn from_path(path: PathBuf) -> PyResult<Self> {
892        let file = File::open(path).map_err(|_| PyFileNotFoundError::new_err("File not found"))?;
893        let mut reader = BufReader::new(file);
894
895        let magic = reader
896            .read_u32::<LittleEndian>()
897            .map_err(|_| InvalidNavFileError::new_err("Could not read magic number"))?;
898        if magic != Self::MAGIC {
899            return Err(InvalidNavFileError::new_err("Unexpected magic number"));
900        }
901
902        let version = reader
903            .read_u32::<LittleEndian>()
904            .map_err(|_| InvalidNavFileError::new_err("Could not read version number"))?;
905        if !(30..=35).contains(&version) {
906            return Err(InvalidNavFileError::new_err("Unsupported nav version"));
907        }
908
909        let sub_version = reader
910            .read_u32::<LittleEndian>()
911            .map_err(|_| InvalidNavFileError::new_err("Could not read sub version number"))?;
912
913        let unk1 = reader
914            .read_u32::<LittleEndian>()
915            .map_err(|_| InvalidNavFileError::new_err("Could not read unk1"))?;
916        let is_analyzed = (unk1 & 0x0000_0001) > 0;
917
918        let polygons = if version >= 31 {
919            Some(Self::read_polygons(&mut reader, version)?)
920        } else {
921            None
922        };
923
924        if version >= 32 {
925            reader
926                .read_u32::<LittleEndian>()
927                .map_err(|_| InvalidNavFileError::new_err("Failed to skip unk2: {}"))?; // Skip unk2
928        }
929        if version >= 35 {
930            reader
931                .read_u32::<LittleEndian>()
932                .map_err(|_| InvalidNavFileError::new_err("Failed to skip unk3: {}"))?; // Skip unk3
933        }
934
935        let areas = Self::read_areas(&mut reader, polygons.as_ref(), version)?;
936        Ok(Self::new(version, sub_version, areas, is_analyzed))
937    }
938}
939
940pub fn areas_audible<T: AreaLike>(area1: &T, area2: &T) -> bool {
941    area1.centroid().distance(&area2.centroid()) <= f64::from(FOOTSTEP_RANGE)
942}
943
944/// Checks if two areas are visible to each other.
945///
946/// Area positions are on the floor, so a height correction to eye level is applied.
947/// Note that this is conservative and can have false negatives for "actual" visibility.
948/// For example if one player can see the feet of another player, but not the head.
949pub fn areas_visible<T: AreaLike>(area1: &T, area2: &T, vis_checker: &CollisionChecker) -> bool {
950    let height_correction = PLAYER_EYE_LEVEL;
951
952    let area1_centroid = area1.centroid();
953    let area2_centroid = area2.centroid();
954
955    let used_centroid1 = Position::new(
956        area1_centroid.x,
957        area1_centroid.y,
958        area1_centroid.z + height_correction,
959    );
960    let used_centroid2 = Position::new(
961        area2_centroid.x,
962        area2_centroid.y,
963        area2_centroid.z + height_correction,
964    );
965
966    vis_checker.connection_unobstructed(used_centroid1, used_centroid2)
967}
968
969/// Get or build a cache of visibility between all area pairs in a nav mesh.
970///
971/// # Panics
972///
973/// Will panic if opening or reading from an existing cache file fails.
974/// Or if creation and writing to a new cache file fails.
975#[must_use]
976pub fn get_visibility_cache(
977    map_name: &str,
978    granularity: usize,
979    nav: &Nav,
980    vis_checker: &CollisionChecker,
981    safe_to_file: bool,
982) -> HashMap<(u32, u32), bool> {
983    let tqdm_config = Config::new().with_leave(true);
984    let cache_path_str =
985        format!("./data/collisions/{map_name}_{granularity}_visibility_cache.vis_cache");
986    let cache_path = Path::new(&cache_path_str);
987    if cache_path.exists() {
988        println!("Loading visibility cache from binary.");
989        let file = File::open(cache_path).unwrap();
990        deserialize_from(file).unwrap()
991    } else {
992        println!("Building visibility cache from scratch.");
993        let visibility_cache = iproduct!(&nav.areas, &nav.areas)
994            .collect::<Vec<_>>()
995            .par_iter()
996            .tqdm_config(tqdm_config.with_desc("Building visibility cache"))
997            .map(|((area_id, area), (other_area_id, other_area))| {
998                let visible = areas_visible(*area, *other_area, vis_checker);
999                ((**area_id, **other_area_id), visible)
1000            })
1001            .collect();
1002        if safe_to_file {
1003            let mut file = create_file_with_parents(cache_path);
1004            serialize_into(&mut file, &visibility_cache).unwrap();
1005        }
1006        visibility_cache
1007    }
1008}
1009
1010/// Checks if two areas are walkable to each other.
1011///
1012/// Requires a collision checker that includes player clippings.
1013/// For walkability we need to account for player width and height.
1014/// For height we also need to consider crouching.
1015pub fn areas_walkable<T: AreaLike>(area1: &T, area2: &T, walk_checker: &CollisionChecker) -> bool {
1016    let height = if area1.requires_crouch() || area2.requires_crouch() {
1017        PLAYER_CROUCH_HEIGHT
1018    } else {
1019        PLAYER_HEIGHT
1020    };
1021    // Using the full width can slightly mess up some tight corners, so use 90% of it.
1022    let width = 0.9 * PLAYER_WIDTH;
1023
1024    let area1_centroid = area1.centroid();
1025    let area2_centroid = area2.centroid();
1026
1027    let dx = area2_centroid.x - area1_centroid.x;
1028    let dy = area2_centroid.y - area1_centroid.y;
1029    let angle = dx.atan2(dy);
1030
1031    for (width_correction, height_correction) in iproduct!([width / 2.0, -width / 2.0], [height]) {
1032        let dx_corr = width_correction * angle.cos();
1033        let dy_corr = width_correction * angle.sin();
1034
1035        let used_centroid1 = Position::new(
1036            area1_centroid.x + dx_corr,
1037            area1_centroid.y + dy_corr,
1038            area1_centroid.z + height_correction,
1039        );
1040        let used_centroid2 = Position::new(
1041            area2_centroid.x + dx_corr,
1042            area2_centroid.y + dy_corr,
1043            area2_centroid.z + height_correction,
1044        );
1045        if !walk_checker.connection_unobstructed(used_centroid1, used_centroid2) {
1046            return false;
1047        }
1048    }
1049    true
1050}
1051
1052/// Get or build a cache of walkability between all area pairs in a nav mesh.
1053///
1054/// # Panics
1055///
1056/// Will panic if opening or reading from an existing cache file fails.
1057/// Or if creation and writing to a new cache file fails.
1058#[allow(dead_code)]
1059#[must_use]
1060fn get_walkability_cache(
1061    map_name: &str,
1062    granularity: usize,
1063    nav: &Nav,
1064    walk_checker: &CollisionChecker,
1065) -> HashMap<(u32, u32), bool> {
1066    let tqdm_config = Config::new().with_leave(true);
1067    let cache_path_str =
1068        format!("./data/collisions/{map_name}_{granularity}_walkability_cache.json");
1069    let cache_path = Path::new(&cache_path_str);
1070    if cache_path.exists() {
1071        let file = File::open(cache_path).unwrap();
1072        serde_json::from_reader(file).unwrap()
1073    } else {
1074        let mut file = create_file_with_parents(cache_path);
1075        let mut walkability_cache = HashMap::default();
1076        for ((area_id, area), (other_area_id, other_area)) in iproduct!(&nav.areas, &nav.areas)
1077            .tqdm_config(tqdm_config.with_desc("Building walkability cache"))
1078        {
1079            let visible = areas_walkable(area, other_area, walk_checker);
1080            walkability_cache.insert((*area_id, *other_area_id), visible);
1081        }
1082        serde_json::to_writer(&mut file, &walkability_cache).unwrap();
1083        walkability_cache
1084    }
1085}
1086
1087/// `NavArea` variant that includes the original area IDs that the new area is based on.
1088#[derive(Debug, Clone, Deserialize, Serialize)]
1089struct NewNavArea {
1090    pub area_id: u32,
1091    pub dynamic_attribute_flags: DynamicAttributeFlags,
1092    pub corners: Vec<Position>,
1093    pub connections: HashSet<u32>,
1094    pub ladders_above: HashSet<u32>,
1095    pub ladders_below: HashSet<u32>,
1096    pub orig_ids: HashSet<u32>,
1097    centroid: Position,
1098}
1099
1100impl NewNavArea {
1101    pub fn new(
1102        corners: Vec<Position>,
1103        orig_ids: HashSet<u32>,
1104        ladders_above: HashSet<u32>,
1105        ladders_below: HashSet<u32>,
1106        dynamic_attribute_flags: DynamicAttributeFlags,
1107        connections: HashSet<u32>,
1108    ) -> Self {
1109        let centroid = centroid(&corners);
1110        Self {
1111            area_id: 0,
1112            dynamic_attribute_flags,
1113            corners,
1114            connections,
1115            ladders_above,
1116            ladders_below,
1117            orig_ids,
1118            centroid,
1119        }
1120    }
1121}
1122
1123impl AreaLike for NewNavArea {
1124    fn centroid(&self) -> Position {
1125        self.centroid
1126    }
1127    fn requires_crouch(&self) -> bool {
1128        self.dynamic_attribute_flags == CROUCHING_ATTRIBUTE_FLAG
1129    }
1130
1131    fn area_id(&self) -> u32 {
1132        self.area_id
1133    }
1134}
1135
1136#[derive(Debug, Clone)]
1137struct AdditionalNavAreaInfo {
1138    pub polygon: Polygon,
1139    pub z_level: f64,
1140}
1141
1142/// Generate a grid of new navigation areas based on the original areas.
1143#[allow(clippy::cast_precision_loss)]
1144fn create_new_nav_areas(
1145    nav_areas: &HashMap<u32, NavArea>,
1146    grid_granularity: usize,
1147    xs: &[f64],
1148    ys: &[f64],
1149    area_extra_info: &HashMap<u32, AdditionalNavAreaInfo>,
1150    tqdm_config: Config,
1151) -> (Vec<NewNavArea>, HashMap<u32, HashSet<u32>>) {
1152    // Get the boundaries of the original areas
1153    let min_x = *xs.iter().min_by(|a, b| a.partial_cmp(b).unwrap()).unwrap();
1154    let max_x = *xs.iter().max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap();
1155    let min_y = *ys.iter().min_by(|a, b| a.partial_cmp(b).unwrap()).unwrap();
1156    let max_y = *ys.iter().max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap();
1157
1158    // Determine cell size of the new areas
1159    let cell_width = (max_x - min_x) / grid_granularity as f64;
1160    let cell_height = (max_y - min_y) / grid_granularity as f64;
1161
1162    let mut new_cells: Vec<NewNavArea> = Vec::new();
1163
1164    // Build the actual areas for the grid.
1165    for (i, j) in iproduct!(0..grid_granularity, 0..grid_granularity)
1166        .tqdm_config(tqdm_config.with_desc("Creating grid cell"))
1167    {
1168        // Information for the new cell
1169        let cell_min_x = (j as f64).mul_add(cell_width, min_x);
1170        let cell_min_y = (i as f64).mul_add(cell_height, min_y);
1171        let cell_max_x = cell_min_x + cell_width;
1172        let cell_max_y = cell_min_y + cell_height;
1173        let center_x = (cell_min_x + cell_max_x) / 2.0;
1174        let center_y = (cell_min_y + cell_max_y) / 2.0;
1175        let center_point = Point::new(center_x, center_y);
1176
1177        let cell_poly = Polygon::new(
1178            LineString::from(vec![
1179                (cell_min_x, cell_min_y),
1180                (cell_max_x, cell_min_y),
1181                (cell_max_x, cell_max_y),
1182                (cell_min_x, cell_max_y),
1183            ]),
1184            vec![],
1185        );
1186
1187        // TODO: Create tiles and their z coordinate by player clipping collisions
1188        // with heaven to floor rays?
1189        // Get all the original areas that the centroid of the new cell is in in 2D
1190        // Also get all cells that the new area intersects with, also in 2D.
1191        let mut primary_origs: HashSet<u32> = HashSet::default();
1192        let mut extra_orig_ids: HashSet<u32> = HashSet::default();
1193        for (area_id, info) in area_extra_info {
1194            if info.polygon.contains(&center_point) {
1195                primary_origs.insert(*area_id);
1196            } else if info.polygon.intersects(&cell_poly) {
1197                extra_orig_ids.insert(*area_id);
1198            }
1199        }
1200
1201        // Skip cells that are outside the bounds of the original map (Holes or irregular shapes)
1202        if primary_origs.is_empty() && extra_orig_ids.is_empty() {
1203            continue;
1204        }
1205
1206        // If an area has no old area that its center is in, then assign the closest intersecting one.
1207        let primary_origs = if primary_origs.is_empty() {
1208            let min_id = extra_orig_ids.iter().min_by(|a, b| {
1209                let distance_a = Euclidean.distance(
1210                    &area_extra_info[*a].polygon.centroid().unwrap(),
1211                    &center_point,
1212                );
1213
1214                let distance_b = Euclidean.distance(
1215                    &area_extra_info[*b].polygon.centroid().unwrap(),
1216                    &center_point,
1217                );
1218                distance_a
1219                    .partial_cmp(&distance_b)
1220                    .unwrap_or(Ordering::Equal)
1221            });
1222            HashSet::from_iter([*min_id.unwrap()])
1223        } else {
1224            primary_origs
1225        };
1226
1227        // Generate one new nav area for each old one that the cell is based on.
1228        for primary in primary_origs {
1229            let mut cell_orig_ids = HashSet::from_iter([primary]);
1230
1231            // The new cell z is based on the inverse distance weighting of the old area corners.
1232            // Just taking the avg z leads to issues with long tiles on slopes.
1233            let primary_z =
1234                inverse_distance_weighting(&nav_areas[&primary].corners, (center_x, center_y));
1235
1236            for other in &extra_orig_ids {
1237                if *other != primary
1238                    && (primary_z - area_extra_info[other].z_level).abs() <= JUMP_HEIGHT
1239                {
1240                    cell_orig_ids.insert(*other);
1241                }
1242            }
1243
1244            let rep_level = (primary_z * 100.0).round() / 100.0;
1245            let corners = vec![
1246                Position::new(cell_min_x, cell_min_y, rep_level),
1247                Position::new(cell_max_x, cell_min_y, rep_level),
1248                Position::new(cell_max_x, cell_max_y, rep_level),
1249                Position::new(cell_min_x, cell_max_y, rep_level),
1250            ];
1251
1252            let primary_area = &nav_areas[&primary];
1253            new_cells.push(NewNavArea::new(
1254                corners,
1255                cell_orig_ids,
1256                HashSet::from_iter(primary_area.ladders_above.clone()),
1257                HashSet::from_iter(primary_area.ladders_below.clone()),
1258                primary_area.dynamic_attribute_flags,
1259                HashSet::default(),
1260            ));
1261        }
1262    }
1263    println!(); // Newline after tqdm so bars dont override each other.
1264
1265    let old_to_new_children = build_old_to_new_mapping(&mut new_cells);
1266
1267    (new_cells, old_to_new_children)
1268}
1269
1270/// Build a mapping of old area IDs to all new areas that they are connected to.
1271#[allow(clippy::cast_possible_truncation)]
1272fn build_old_to_new_mapping(new_cells: &mut [NewNavArea]) -> HashMap<u32, HashSet<u32>> {
1273    let mut old_to_new_children: HashMap<u32, HashSet<u32>> = HashMap::default();
1274
1275    for (idx, new_cell) in new_cells.iter_mut().enumerate() {
1276        new_cell.area_id = idx as u32;
1277        for orig_id in &new_cell.orig_ids {
1278            old_to_new_children
1279                .entry(*orig_id)
1280                .or_default()
1281                .insert(new_cell.area_id);
1282        }
1283    }
1284    old_to_new_children
1285}
1286
1287/// Build a regularized navigation mesh with a fixed granularity from the original navigation areas.
1288///
1289/// First build a grid of cells and assign each cell to the closest original area.
1290/// Also consider other original areas that intersect the cell.
1291/// Then build connections between the new cells based on physical reachability in the game.
1292/// Finally ensure that old connections are preserved.
1293#[allow(clippy::cast_possible_truncation)]
1294#[allow(clippy::cast_precision_loss)]
1295#[must_use]
1296pub fn regularize_nav_areas(
1297    nav_areas: &HashMap<u32, NavArea>,
1298    grid_granularity: usize,
1299    walk_checker: &CollisionChecker,
1300) -> HashMap<u32, NavArea> {
1301    let tqdm_config = Config::new().with_leave(true);
1302
1303    let mut xs: Vec<f64> = Vec::new();
1304    let mut ys: Vec<f64> = Vec::new();
1305    let mut area_extra_info: HashMap<u32, AdditionalNavAreaInfo> = HashMap::default();
1306
1307    // Precompute the 2D polygon projection and an average-z for each nav area
1308    for (area_id, area) in nav_areas {
1309        let coords: Vec<(f64, f64)> = area.corners.iter().map(|c| (c.x, c.y)).collect();
1310        let poly = Polygon::new(LineString::from(coords), vec![]);
1311        let avg_z: f64 =
1312            area.corners.iter().map(|corner| corner.z).sum::<f64>() / area.corners.len() as f64;
1313        area_extra_info.insert(
1314            *area_id,
1315            AdditionalNavAreaInfo {
1316                polygon: poly,
1317                z_level: avg_z,
1318            },
1319        );
1320
1321        for corner in &area.corners {
1322            xs.push(corner.x);
1323            ys.push(corner.y);
1324        }
1325    }
1326
1327    if xs.is_empty() || ys.is_empty() {
1328        return HashMap::default();
1329    }
1330
1331    // Get the base grid of the new areas
1332    let (mut new_nav_areas, old_to_new_children) = create_new_nav_areas(
1333        nav_areas,
1334        grid_granularity,
1335        &xs,
1336        &ys,
1337        &area_extra_info,
1338        tqdm_config.clone(),
1339    );
1340
1341    // add_intra_area_connections(
1342    //     &mut new_nav_areas,
1343    //     &old_to_new_children,
1344    //     tqdm_config.clone(),
1345    // );
1346
1347    add_connections_by_reachability(&mut new_nav_areas, walk_checker, tqdm_config.clone());
1348
1349    ensure_inter_area_connections(
1350        &mut new_nav_areas,
1351        nav_areas,
1352        &old_to_new_children,
1353        tqdm_config,
1354    );
1355
1356    new_nav_areas
1357        .into_iter()
1358        .enumerate()
1359        .map(|(idx, area)| (idx as u32, area.into()))
1360        .collect()
1361}
1362
1363/// Ensure that a previous area A that was connected to another area B still has this connection
1364/// via at least one new area A' that is based on A being connected to a new area B' that is based on B.
1365fn ensure_inter_area_connections(
1366    new_nav_areas: &mut [NewNavArea],
1367    nav_areas: &HashMap<u32, NavArea>,
1368    old_to_new_children: &HashMap<u32, HashSet<u32>>,
1369    tqdm_config: Config,
1370) {
1371    // Ensure old connections are preserved
1372    for (a_idx, area_a) in nav_areas
1373        .iter()
1374        .tqdm_config(tqdm_config.with_desc("Ensuring old connections"))
1375    {
1376        // These are old areas that have no assigned new ones. This can happen if they are
1377        // never the primary area AND have too large a height difference with all primaries.
1378        // Can think if there is a useful way to still incorporate them later.
1379        let Some(children_of_a) = old_to_new_children.get(a_idx) else {
1380            continue;
1381        };
1382        for neighbor_of_a_idx in &area_a.connections {
1383            let Some(children_of_neighbor_of_a) = old_to_new_children.get(neighbor_of_a_idx) else {
1384                continue;
1385            };
1386
1387            let mut neighbors_of_children_of_a: HashSet<&u32> = HashSet::from_iter(children_of_a);
1388            for child_of_a in children_of_a {
1389                neighbors_of_children_of_a.extend(&new_nav_areas[*child_of_a as usize].connections);
1390            }
1391
1392            if children_of_neighbor_of_a
1393                .iter()
1394                .any(|x| neighbors_of_children_of_a.contains(x))
1395            {
1396                // If there is overlap, continue the outer loop
1397                continue;
1398            }
1399
1400            let pairs_of_children =
1401                iproduct!(children_of_a.iter(), children_of_neighbor_of_a.iter());
1402
1403            let pairs_of_children = pairs_of_children.sorted_by(|pair_a, pair_b| {
1404                new_nav_areas[*pair_a.0 as usize]
1405                    .centroid()
1406                    .distance_2d(&new_nav_areas[*pair_a.1 as usize].centroid())
1407                    .partial_cmp(
1408                        &new_nav_areas[*pair_b.0 as usize]
1409                            .centroid()
1410                            .distance_2d(&new_nav_areas[*pair_b.1 as usize].centroid()),
1411                    )
1412                    .unwrap()
1413            });
1414
1415            // Ideally we would just take the overall min here instead of sorting
1416            // and taking 3. But due to map weirdnesses it can happen that exactly
1417            // this one field does not have the proper connection so we need to
1418            // have a buffer. Trying 3 for now.
1419            for pair_of_children in pairs_of_children.take(3) {
1420                new_nav_areas
1421                    .get_mut(*pair_of_children.0 as usize)
1422                    .unwrap()
1423                    .connections
1424                    .insert(*pair_of_children.1);
1425            }
1426        }
1427    }
1428    println!();
1429    // Newline after tqdm so bars dont override each other.
1430}
1431
1432/// Add connections between areas based on walkability (`areas_walkable`)
1433/// and the ability to physically reach the area via a jump in the game.
1434/// Also accounts for connections via ladders.
1435fn add_connections_by_reachability(
1436    new_nav_areas: &mut Vec<NewNavArea>,
1437    walk_checker: &CollisionChecker,
1438    tqdm_config: Config,
1439) {
1440    let new_connections: Vec<HashSet<u32>> = new_nav_areas
1441        .par_iter()
1442        .tqdm_config(tqdm_config.with_desc("Connections from reachability"))
1443        .map(|area| {
1444            let mut conns = HashSet::default();
1445            for other_area in &*new_nav_areas {
1446                if area.area_id == other_area.area_id
1447                    || area.connections.contains(&other_area.area_id)
1448                {
1449                    continue;
1450                }
1451
1452                if (!area.ladders_above.is_disjoint(&other_area.ladders_below))
1453                    || (!area.ladders_below.is_disjoint(&other_area.ladders_above))
1454                    || (area.centroid().can_jump_to(&other_area.centroid())
1455                        && areas_walkable(area, other_area, walk_checker))
1456                {
1457                    conns.insert(other_area.area_id);
1458                }
1459            }
1460            conns
1461        })
1462        .collect();
1463    for (area, conns) in new_nav_areas.iter_mut().zip(new_connections) {
1464        area.connections.extend(conns);
1465    }
1466    println!();
1467    // Newline after tqdm so bars dont override each other.
1468}
1469
1470/// Add connections between new areas that comprise the same old areas.
1471///
1472/// Build connectivity based solely on the new cell's `orig_ids`.
1473/// For a new cell A with orig set `A_orig`, connect to new cell B with orig set `B_orig` if:
1474/// ∃ a in `A_orig` and b in `B_orig` with a == b or b in `nav_areas`[a].connections
1475#[allow(dead_code)]
1476fn add_intra_area_connections(
1477    new_nav_areas: &mut [NewNavArea],
1478    old_to_new_children: &HashMap<u32, HashSet<u32>>,
1479    tqdm_config: Config,
1480) {
1481    for new_area in &mut new_nav_areas
1482        .iter_mut()
1483        .tqdm_config(tqdm_config.with_desc("Connections from inheritance"))
1484    {
1485        let parent_areas = &new_area.orig_ids;
1486        for parent_area in parent_areas {
1487            let siblings = &old_to_new_children[parent_area];
1488
1489            for sibling in siblings {
1490                if *sibling != new_area.area_id {
1491                    new_area.connections.insert(*sibling);
1492                }
1493            }
1494        }
1495    }
1496    println!(); // Newline after tqdm so bars dont override each other.
1497}
1498
1499#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, Copy, IntoPyObject)]
1500pub struct GroupId(u32);
1501
1502/// Groups the nav areas into groups of a certain size.
1503///
1504/// Only works for meshes that are rectangular and have the same cell size.
1505///
1506/// Mainly used for building spreads and plotting them to avoid too many plots
1507/// for close but not explicitly path connected areas. Reason for that is that
1508/// paths are likely to skip a lot of areas because of jumpability connections.
1509///
1510/// Returns mappings:
1511/// `GroupID` -> [`AreaID`]
1512/// `AreaID` -> `GroupID`
1513///
1514/// # Panics
1515///
1516/// Will panic if a centroid comparison returns `None`. Basically if there is a NaN somewhere.
1517#[allow(clippy::cast_possible_truncation)]
1518#[allow(clippy::cast_sign_loss)]
1519pub fn group_nav_areas(nav_areas: &[&NavArea], group_size: usize) -> HashMap<u32, GroupId> {
1520    println!("Grouping areas");
1521    let mut block_map: HashMap<(usize, usize), Vec<&NavArea>> = HashMap::default();
1522
1523    // Get row and column number of each area in the grid.
1524    // For that we first need to get the starting point of the grid (min_x, min_y)
1525    let min_x = nav_areas
1526        .iter()
1527        .min_by(|a, b| a.centroid.x.partial_cmp(&b.centroid.x).unwrap())
1528        .unwrap()
1529        .centroid
1530        .x;
1531    let min_y = nav_areas
1532        .iter()
1533        .min_by(|a, b| a.centroid.y.partial_cmp(&b.centroid.y).unwrap())
1534        .unwrap()
1535        .centroid
1536        .y;
1537
1538    // And the size of each cell in the grid
1539    // This requires that all cells are of the same size
1540    let first_area = nav_areas.first().unwrap();
1541    let tile_min_x = first_area
1542        .corners
1543        .iter()
1544        .map(|c| c.x)
1545        .fold(f64::INFINITY, f64::min);
1546    let tile_min_y = first_area
1547        .corners
1548        .iter()
1549        .map(|c| c.y)
1550        .fold(f64::INFINITY, f64::min);
1551    let tile_max_x = first_area
1552        .corners
1553        .iter()
1554        .map(|c| c.x)
1555        .fold(f64::NEG_INFINITY, f64::max);
1556    let tile_max_y = first_area
1557        .corners
1558        .iter()
1559        .map(|c| c.y)
1560        .fold(f64::NEG_INFINITY, f64::max);
1561
1562    let delta_x = tile_max_x - tile_min_x;
1563    let delta_y = tile_max_y - tile_min_y;
1564
1565    // Get the group that each area belongs to based on just x-y coordinates
1566    for area in nav_areas {
1567        let cell_x = ((area.centroid.x - min_x) / delta_x).round() as usize;
1568        let cell_y = ((area.centroid.y - min_y) / delta_y).round() as usize;
1569        block_map
1570            .entry((cell_x / group_size, cell_y / group_size))
1571            .or_default()
1572            .push(area);
1573    }
1574
1575    // Sorting for deterministic results and nicer plotting.
1576    let sorted_blocks: Vec<Vec<&NavArea>> = block_map
1577        .into_iter()
1578        .sorted_by_key(|(k, _v)| *k)
1579        .map(|(_, v)| v)
1580        .collect();
1581
1582    // let mut group_to_areas: HashMap<GroupId, Vec<u32>> = HashMap::default();
1583    let mut area_to_group: HashMap<u32, GroupId> = HashMap::default();
1584    let mut next_group_id: u32 = 0;
1585
1586    // Loop over each x-y grid group
1587    for mut areas in sorted_blocks {
1588        areas.sort_by_key(|a| {
1589            let cell_x = ((a.centroid.x - min_x) / delta_x).round() as usize;
1590            let cell_y = ((a.centroid.y - min_y) / delta_y).round() as usize;
1591            (cell_x, cell_y, a.area_id)
1592        });
1593
1594        // We do not want to have multiple levels of z in any group.
1595        let mut z_groups: Vec<Vec<&NavArea>> = Vec::new();
1596        for area in areas {
1597            let cell_coord = (
1598                ((area.centroid.x - min_x) / delta_x).round() as usize,
1599                ((area.centroid.y - min_y) / delta_y).round() as usize,
1600            );
1601            let mut found = false;
1602
1603            for group in &mut z_groups {
1604                // The new area should not go into this group of there is another area
1605                // with identical x-y coordinates.
1606                if group.iter().any(|a| {
1607                    let ax = ((a.centroid.x - min_x) / delta_x).round() as usize;
1608                    let ay = ((a.centroid.y - min_y) / delta_y).round() as usize;
1609                    (ax, ay) == cell_coord
1610                }) {
1611                    continue;
1612                }
1613
1614                // The area should be within jump height of all other areas in the group.
1615                if group
1616                    .iter()
1617                    .all(|a| (a.centroid.z - area.centroid.z).abs() <= JUMP_HEIGHT)
1618                {
1619                    group.push(area);
1620                    found = true;
1621                    break;
1622                }
1623            }
1624
1625            // If it can not be added to any existing group and it created a new
1626            if !found {
1627                z_groups.push(vec![area]);
1628            }
1629        }
1630
1631        // Build the group to area and area to group mappings
1632        for group in z_groups {
1633            let group_id = next_group_id;
1634            next_group_id += 1;
1635            // group_to_areas.insert(GroupId(group_id), group.iter().map(|a| a.area_id).collect());
1636            for area in group {
1637                area_to_group.insert(area.area_id, GroupId(group_id));
1638            }
1639        }
1640    }
1641
1642    area_to_group
1643}
1644
1645#[pyfunction]
1646#[allow(clippy::needless_pass_by_value)]
1647#[pyo3(name = "regularize_nav_areas")]
1648#[must_use]
1649pub fn py_regularize_nav_areas(
1650    nav_areas: HashMap<u32, NavArea>,
1651    grid_granularity: usize,
1652    walk_checker: &CollisionChecker,
1653) -> HashMap<u32, NavArea> {
1654    regularize_nav_areas(&nav_areas, grid_granularity, walk_checker)
1655}
1656
1657#[pyfunction]
1658#[allow(clippy::needless_pass_by_value)]
1659#[pyo3(name = "group_nav_areas")]
1660#[must_use]
1661pub fn py_group_nav_areas(nav_areas: Vec<NavArea>, group_size: usize) -> HashMap<u32, GroupId> {
1662    let nav_refs: Vec<&NavArea> = nav_areas.iter().collect();
1663    group_nav_areas(&nav_refs, group_size)
1664}