Skip to main content

wowlab_types/types/sim/encounter/
spatial.rs

1// #t(file: rust_alloc_in_loop) validation allocates precise indexed field paths only during construction, never on combat hot paths
2// #t(file: rust_unchecked_indexing) validated rings and actor positions are indexed only after structural bounds checks
3// #t(file: rust_magic_numbers) numeric literals are planar geometry formulas
4// #t(file: rust_missing_error_context) lower-level position errors are replaced with the owning encounter field path
5
6use super::{
7    super::FastSet,
8    EncounterValidationError, GEOMETRY_EPSILON, SpatialTransform, StaticObstacle,
9    StaticSpatialScene,
10    geometry::{
11        point_in_or_on_ring, point_on_segment, point_strictly_in_ring, rings_intersect,
12        validate_ring,
13    },
14    invalid,
15    validation::validate_slug,
16};
17
18impl StaticSpatialScene {
19    /// Validate layer ordering and all obstacle geometry.
20    ///
21    /// # Errors
22    ///
23    /// Returns the first invalid layer or obstacle field encountered.
24    pub fn validate(&self) -> Result<(), EncounterValidationError> {
25        if self.layers.is_empty() {
26            return Err(invalid(
27                "spatial_scene.layers",
28                "scene must contain at least one layer",
29            ));
30        }
31
32        let mut slugs = FastSet::default();
33
34        for (index, layer) in self.layers.iter().enumerate() {
35            validate_layer(layer, index, &mut slugs)?;
36        }
37
38        for (index, obstacle) in self.obstacles.iter().enumerate() {
39            let path = format!("spatial_scene.obstacles[{index}]");
40            let layer = validate_obstacle(obstacle, &path)?;
41
42            if usize::from(layer.0) >= self.layers.len() {
43                return Err(invalid(&path, format!("missing spatial layer {}", layer.0)));
44            }
45        }
46
47        Ok(())
48    }
49}
50
51fn validate_layer<'a>(
52    layer: &'a super::SpatialLayerDefinition,
53    index: usize,
54    slugs: &mut FastSet<&'a str>,
55) -> Result<(), EncounterValidationError> {
56    if usize::from(layer.id.0) != index {
57        return Err(invalid(
58            format!("spatial_scene.layers[{index}].id"),
59            "layer ids must be dense and ordered",
60        ));
61    }
62
63    validate_slug(&layer.slug, &format!("spatial_scene.layers[{index}].slug"))?;
64
65    if !slugs.insert(layer.slug.as_str()) {
66        return Err(invalid(
67            "spatial_scene.layers",
68            format!("duplicate layer slug {}", layer.slug),
69        ));
70    }
71
72    Ok(())
73}
74
75#[expect(
76    clippy::map_err_ignore,
77    reason = "coordinate errors are deliberately replaced by the owning obstacle field path"
78)]
79fn validate_obstacle(
80    obstacle: &StaticObstacle,
81    path: &str,
82) -> Result<super::SpatialLayerId, EncounterValidationError> {
83    match obstacle {
84        StaticObstacle::Segment { layer, start, end } => {
85            start
86                .validate()
87                .map_err(|_| invalid(path, "segment start must be finite"))?;
88            end.validate()
89                .map_err(|_| invalid(path, "segment end must be finite"))?;
90
91            if start.distance(*end) <= GEOMETRY_EPSILON {
92                return Err(invalid(path, "segment endpoints must differ"));
93            }
94
95            Ok(*layer)
96        }
97        StaticObstacle::Polygon {
98            layer,
99            exterior,
100            holes,
101        } => {
102            validate_polygon(exterior, holes, path)?;
103
104            Ok(*layer)
105        }
106    }
107}
108
109fn validate_polygon(
110    exterior: &[super::Position2],
111    holes: &[Vec<super::Position2>],
112    path: &str,
113) -> Result<(), EncounterValidationError> {
114    validate_ring(exterior, &format!("{path}.exterior"))?;
115
116    for (hole_index, hole) in holes.iter().enumerate() {
117        let hole_path = format!("{path}.holes[{hole_index}]");
118
119        validate_ring(hole, &hole_path)?;
120
121        if !point_strictly_in_ring(hole[0], exterior) {
122            return Err(invalid(hole_path, "hole must be strictly inside exterior"));
123        }
124
125        if rings_intersect(hole, exterior) {
126            return Err(invalid(
127                format!("{path}.holes[{hole_index}]"),
128                "hole may not touch or cross exterior",
129            ));
130        }
131    }
132
133    validate_hole_separation(holes, path)
134}
135
136fn validate_hole_separation(
137    holes: &[Vec<super::Position2>],
138    path: &str,
139) -> Result<(), EncounterValidationError> {
140    for left in 0..holes.len() {
141        for right in left + 1..holes.len() {
142            if rings_intersect(&holes[left], &holes[right])
143                || point_in_or_on_ring(holes[left][0], &holes[right])
144                || point_in_or_on_ring(holes[right][0], &holes[left])
145            {
146                return Err(invalid(
147                    path,
148                    "polygon holes may not touch, overlap, or nest",
149                ));
150            }
151        }
152    }
153
154    Ok(())
155}
156
157pub(super) fn validate_not_in_obstacle(
158    transform: SpatialTransform,
159    scene: &StaticSpatialScene,
160    path: &str,
161) -> Result<(), EncounterValidationError> {
162    for obstacle in &scene.obstacles {
163        match obstacle {
164            StaticObstacle::Segment { layer, start, end }
165                if *layer == transform.layer
166                    && point_on_segment(transform.position, *start, *end) =>
167            {
168                return Err(invalid(path, "actor transform touches a blocking segment"));
169            }
170            StaticObstacle::Polygon {
171                layer,
172                exterior,
173                holes,
174            } if *layer == transform.layer => {
175                let in_exterior = point_in_or_on_ring(transform.position, exterior);
176                let in_hole = holes
177                    .iter()
178                    .any(|hole| point_strictly_in_ring(transform.position, hole));
179
180                if in_exterior && !in_hole {
181                    return Err(invalid(
182                        path,
183                        "actor transform is inside or on a blocking polygon",
184                    ));
185                }
186
187                if holes.iter().any(|hole| {
188                    hole.windows(2)
189                        .any(|edge| point_on_segment(transform.position, edge[0], edge[1]))
190                }) {
191                    return Err(invalid(
192                        path,
193                        "actor transform touches a blocking polygon boundary",
194                    ));
195                }
196            }
197            _ => {}
198        }
199    }
200
201    Ok(())
202}