jagua_rs/geometry/
shape_modification.rs

1use itertools::Itertools;
2use log::{debug, error, info, warn};
3use ordered_float::OrderedFloat;
4use serde::{Deserialize, Serialize};
5use std::cmp::Ordering;
6
7use crate::geometry::geo_traits::{CollidesWith, DistanceTo};
8use crate::geometry::primitives::Edge;
9use crate::geometry::primitives::Point;
10use crate::geometry::primitives::SPolygon;
11
12use crate::io::ext_repr::ExtSPolygon;
13use crate::io::import;
14use anyhow::{Result, bail};
15
16/// Whether to strictly inflate or deflate when making any modifications to shape.
17/// Depends on the [`position`](crate::collision_detection::hazards::HazardEntity::scope) of the [`HazardEntity`](crate::collision_detection::hazards::HazardEntity) that the shape represents.
18#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
19pub enum ShapeModifyMode {
20    /// Modify the shape to be strictly larger than the original (superset).
21    Inflate,
22    /// Modify the shape to be strictly smaller than the original (subset).
23    Deflate,
24}
25
26#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq)]
27pub struct ShapeModifyConfig {
28    /// Maximum deviation of the simplified polygon with respect to the original polygon area as a ratio.
29    /// If undefined, no simplification is performed.
30    /// See [`simplify_shape`]
31    pub simplify_tolerance: Option<f32>,
32    /// Offset by which to inflate or deflate the polygon.
33    /// If undefined, no offset is applied.
34    /// See [`offset_shape`]
35    pub offset: Option<f32>,
36    /// Definition for narrow concavities that can be closed by a straight edge.
37    /// Defined as a tuple of (max_distance_ratio, max_area_ratio) where:
38    /// - max_distance_ratio: maximum distance between two vertices of a polygon to consider it a narrow concavity, defined as a fraction of the item's diameter.
39    /// - max_area_ratio: maximum area of the sub-shape formed by the vertices between the two vertices, defined as a fraction of the item's area.
40    ///
41    /// If undefined, no narrow concavities will be closed.
42    /// See [`close_narrow_concavities`]
43    pub narrow_concavity_cutoff: Option<(f32, f32)>,
44}
45
46/// Simplifies a [`SPolygon`] by reducing the number of edges.
47///
48/// The simplified shape will either be a subset or a superset of the original shape, depending on the [`ShapeModifyMode`].
49/// The procedure sequentially eliminates edges until either the change in area (ratio)
50/// exceeds `max_area_delta` or the number of edges < 4.
51pub fn simplify_shape(
52    shape: &SPolygon,
53    mode: ShapeModifyMode,
54    max_area_change_ratio: f32,
55) -> SPolygon {
56    let original_area = shape.area;
57
58    let mut ref_points = shape.vertices.clone();
59
60    for _ in 0..shape.n_vertices() {
61        let n_points = ref_points.len() as isize;
62        if n_points < 4 {
63            //can't simplify further
64            break;
65        }
66
67        let mut corners = (0..n_points)
68            .map(|i| {
69                let i_prev = (i - 1).rem_euclid(n_points);
70                let i_next = (i + 1).rem_euclid(n_points);
71                Corner(i_prev as usize, i as usize, i_next as usize)
72            })
73            .collect_vec();
74
75        if mode == ShapeModifyMode::Deflate {
76            //default mode is to inflate, so we need to reverse the order of the corners and flip the corners for deflate mode
77            //reverse the order of the corners
78            corners.reverse();
79            //reverse each corner
80            corners.iter_mut().for_each(|c| c.flip());
81        }
82
83        let mut candidates = vec![];
84
85        let mut prev_corner = corners.last().expect("corners is empty");
86        let mut prev_corner_type = CornerType::from(prev_corner.to_points(&ref_points));
87
88        //Go over all corners and generate candidates
89        for corner in corners.iter() {
90            let corner_type = CornerType::from(corner.to_points(&ref_points));
91
92            //Generate a removal candidate (or not)
93            match (&corner_type, &prev_corner_type) {
94                (CornerType::Concave, _) => candidates.push(Candidate::Concave(*corner)),
95                (CornerType::Collinear, _) => candidates.push(Candidate::Collinear(*corner)),
96                (CornerType::Convex, CornerType::Convex) => {
97                    candidates.push(Candidate::ConvexConvex(*prev_corner, *corner))
98                }
99                (_, _) => {}
100            };
101            (prev_corner, prev_corner_type) = (corner, corner_type);
102        }
103
104        //search the candidate with the smallest change in area that is valid
105        let best_candidate = candidates
106            .iter()
107            .sorted_by_cached_key(|c| {
108                OrderedFloat(calculate_area_delta(&ref_points, c).unwrap_or(f32::INFINITY))
109            })
110            .find(|c| candidate_is_valid(&ref_points, c));
111
112        //if it is within the area change constraints, execute the candidate
113        if let Some(best_candidate) = best_candidate {
114            let new_shape = execute_candidate(&ref_points, best_candidate);
115            let new_shape_area = SPolygon::calculate_area(&new_shape);
116            let area_delta = (new_shape_area - original_area).abs() / original_area;
117            if area_delta <= max_area_change_ratio {
118                debug!(
119                    "[PS] executed {:?} simplification causing {:.2}% area change",
120                    best_candidate,
121                    area_delta * 100.0
122                );
123                ref_points = new_shape;
124            } else {
125                break; //area change too significant
126            }
127        } else {
128            break; //no candidate found
129        }
130    }
131
132    //Convert it back to a simple polygon
133    let simpl_shape = SPolygon::new(ref_points).unwrap();
134
135    if simpl_shape.n_vertices() < shape.n_vertices() {
136        info!(
137            "[PS] simplified from {} to {} edges with {:.3}% area difference",
138            shape.n_vertices(),
139            simpl_shape.n_vertices(),
140            (simpl_shape.area - shape.area) / shape.area * 100.0
141        );
142    } else {
143        info!("[PS] no simplification possible within area change constraints");
144    }
145
146    simpl_shape
147}
148
149fn calculate_area_delta(shape: &[Point], candidate: &Candidate) -> Result<f32, InvalidCandidate> {
150    //calculate the difference in area of the shape if the candidate were to be executed
151    let area = match candidate {
152        Candidate::Collinear(_) => 0.0,
153        Candidate::Concave(c) => {
154            //Triangle formed by i_prev, i and i_next will correspond to the change area
155            let Point(x0, y0) = shape[c.0];
156            let Point(x1, y1) = shape[c.1];
157            let Point(x2, y2) = shape[c.2];
158
159            let area = (x0 * y1 + x1 * y2 + x2 * y0 - x0 * y2 - x1 * y0 - x2 * y1) / 2.0;
160
161            area.abs()
162        }
163        Candidate::ConvexConvex(c1, c2) => {
164            let replacing_vertex = replacing_vertex_convex_convex_candidate(shape, (*c1, *c2))?;
165
166            //the triangle formed by corner c1, c2, and replacing vertex will correspond to the change in area
167            let Point(x0, y0) = shape[c1.1];
168            let Point(x1, y1) = replacing_vertex;
169            let Point(x2, y2) = shape[c2.1];
170
171            let area = (x0 * y1 + x1 * y2 + x2 * y0 - x0 * y2 - x1 * y0 - x2 * y1) / 2.0;
172
173            area.abs()
174        }
175    };
176    Ok(area)
177}
178
179fn candidate_is_valid(shape: &[Point], candidate: &Candidate) -> bool {
180    //ensure the removal/replacement does not create any self intersections
181    match candidate {
182        Candidate::Collinear(_) => true,
183        Candidate::Concave(c) => {
184            let new_edge = Edge::try_new(shape[c.0], shape[c.2]).unwrap();
185            let affected_points = [shape[c.0], shape[c.1], shape[c.2]];
186
187            //check for self-intersections
188            edge_iter(shape)
189                .filter(|l| !affected_points.contains(&l.start))
190                .filter(|l| !affected_points.contains(&l.end))
191                .all(|l| !l.collides_with(&new_edge))
192        }
193        Candidate::ConvexConvex(c1, c2) => {
194            match replacing_vertex_convex_convex_candidate(shape, (*c1, *c2)) {
195                Err(_) => false,
196                Ok(new_vertex) => {
197                    let new_edge_1 = Edge::try_new(shape[c1.0], new_vertex).unwrap();
198                    let new_edge_2 = Edge::try_new(new_vertex, shape[c2.2]).unwrap();
199
200                    let affected_points = [shape[c1.1], shape[c1.0], shape[c2.1], shape[c2.2]];
201
202                    //check for self-intersections
203                    edge_iter(shape)
204                        .filter(|l| !affected_points.contains(&l.start))
205                        .filter(|l| !affected_points.contains(&l.end))
206                        .all(|l| !l.collides_with(&new_edge_1) && !l.collides_with(&new_edge_2))
207                }
208            }
209        }
210    }
211}
212
213fn edge_iter(points: &[Point]) -> impl Iterator<Item = Edge> + '_ {
214    let n_points = points.len();
215    (0..n_points).map(move |i| {
216        let j = (i + 1) % n_points;
217        Edge::try_new(points[i], points[j]).unwrap()
218    })
219}
220
221fn execute_candidate(shape: &[Point], candidate: &Candidate) -> Vec<Point> {
222    let mut points = shape.iter().cloned().collect_vec();
223    match candidate {
224        Candidate::Collinear(c) | Candidate::Concave(c) => {
225            points.remove(c.1);
226        }
227        Candidate::ConvexConvex(c1, c2) => {
228            let replacing_vertex = replacing_vertex_convex_convex_candidate(shape, (*c1, *c2))
229                .expect("invalid candidate cannot be executed");
230            points.remove(c1.1);
231            let other_index = if c1.1 < c2.1 { c2.1 - 1 } else { c2.1 };
232            points.remove(other_index);
233            points.insert(other_index, replacing_vertex);
234        }
235    }
236    points
237}
238
239fn replacing_vertex_convex_convex_candidate(
240    shape: &[Point],
241    (c1, c2): (Corner, Corner),
242) -> Result<Point, InvalidCandidate> {
243    assert_eq!(c1.2, c2.1, "non-consecutive corners {c1:?},{c2:?}");
244    assert_eq!(c1.1, c2.0, "non-consecutive corners {c1:?},{c2:?}");
245
246    let edge_prev = Edge::try_new(shape[c1.0], shape[c1.1]).unwrap();
247    let edge_next = Edge::try_new(shape[c2.2], shape[c2.1]).unwrap();
248
249    calculate_intersection_in_front(&edge_prev, &edge_next).ok_or(InvalidCandidate)
250}
251
252fn calculate_intersection_in_front(l1: &Edge, l2: &Edge) -> Option<Point> {
253    //Calculates the intersection point between l1 and l2 if both were extended in front to infinity.
254
255    //https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection#Given_two_points_on_each_line_segment
256    //vector 1 = [(x1,y1),(x2,y2)[ and vector 2 = [(x3,y3),(x4,y4)[
257    let Point(x1, y1) = l1.start;
258    let Point(x2, y2) = l1.end;
259    let Point(x3, y3) = l2.start;
260    let Point(x4, y4) = l2.end;
261
262    //used formula is slightly different to the one on wikipedia. The orientation of the line segments are flipped
263    //We consider an intersection if t == ]0,1] && u == ]0,1]
264
265    let t_nom = (x2 - x4) * (y4 - y3) - (y2 - y4) * (x4 - x3);
266    let t_denom = (x2 - x1) * (y4 - y3) - (y2 - y1) * (x4 - x3);
267
268    let u_nom = (x2 - x4) * (y2 - y1) - (y2 - y4) * (x2 - x1);
269    let u_denom = (x2 - x1) * (y4 - y3) - (y2 - y1) * (x4 - x3);
270
271    let t = if t_denom != 0.0 { t_nom / t_denom } else { 0.0 };
272
273    let u = if u_denom != 0.0 { u_nom / u_denom } else { 0.0 };
274
275    if t < 0.0 && u < 0.0 {
276        //intersection is in front both vectors
277        Some(Point(x2 + t * (x1 - x2), y2 + t * (y1 - y2)))
278    } else {
279        //no intersection (parallel or not in front)
280        None
281    }
282}
283
284#[derive(Debug, Clone)]
285struct InvalidCandidate;
286
287#[derive(Clone, Debug, PartialEq)]
288enum Candidate {
289    Concave(Corner),
290    ConvexConvex(Corner, Corner),
291    Collinear(Corner),
292}
293
294#[derive(Clone, Copy, Debug, PartialEq)]
295///Corner is defined as the left hand side of points 0-1-2
296struct Corner(pub usize, pub usize, pub usize);
297
298impl Corner {
299    pub fn flip(&mut self) {
300        std::mem::swap(&mut self.0, &mut self.2);
301    }
302
303    pub fn to_points(self, points: &[Point]) -> [Point; 3] {
304        [points[self.0], points[self.1], points[self.2]]
305    }
306}
307
308#[derive(Clone, Copy, Debug, PartialEq)]
309enum CornerType {
310    Concave,
311    Convex,
312    Collinear,
313}
314
315impl CornerType {
316    pub fn from([p1, p2, p3]: [Point; 3]) -> Self {
317        //returns the corner type on the left-hand side p1->p2->p3
318        //From: https://algorithmtutor.com/Computational-Geometry/Determining-if-two-consecutive-segments-turn-left-or-right/
319
320        let p1p2 = (p2.0 - p1.0, p2.1 - p1.1);
321        let p1p3 = (p3.0 - p1.0, p3.1 - p1.1);
322        let cross_prod = p1p2.0 * p1p3.1 - p1p2.1 * p1p3.0;
323
324        //a positive cross product indicates that p2p3 turns to the left with respect to p1p2
325        match cross_prod.partial_cmp(&0.0).expect("cross product is NaN") {
326            Ordering::Less => CornerType::Concave,
327            Ordering::Equal => CornerType::Collinear,
328            Ordering::Greater => CornerType::Convex,
329        }
330    }
331}
332
333/// Offsets a [`SPolygon`] by a certain `distance` either inwards or outwards depending on the [`ShapeModifyMode`].
334/// Relies on the [`geo_offset`](https://crates.io/crates/geo_offset) crate.
335pub fn offset_shape(sp: &SPolygon, mode: ShapeModifyMode, distance: f32) -> Result<SPolygon> {
336    let offset = match mode {
337        ShapeModifyMode::Deflate => -distance,
338        ShapeModifyMode::Inflate => distance,
339    };
340
341    // Convert the SPolygon to a geo_types::Polygon
342    let geo_poly = geo_types::Polygon::new(
343        sp.vertices
344            .iter()
345            .map(|p| (p.0 as f64, p.1 as f64))
346            .collect(),
347        vec![],
348    );
349
350    // Create the offset polygon
351    let geo_poly_offsets = geo_buffer::buffer_polygon_rounded(&geo_poly, offset as f64).0;
352
353    let geo_poly_offset = match geo_poly_offsets.len() {
354        0 => bail!("Offset resulted in an empty polygon"),
355        1 => &geo_poly_offsets[0],
356        _ => {
357            // If there are multiple polygons, we take the first one.
358            // This can happen if the offset creates multiple disconnected parts.
359            warn!("Offset resulted in multiple polygons, taking the first one.");
360            &geo_poly_offsets[0]
361        }
362    };
363
364    // Convert back to internal representation (by using the import function)
365    let ext_s_polygon = ExtSPolygon(
366        geo_poly_offset
367            .exterior()
368            .points()
369            .map(|p| (p.x() as f32, p.y() as f32))
370            .collect_vec(),
371    );
372
373    import::import_simple_polygon(&ext_s_polygon)
374}
375
376/// Closes narrow concavities in a [`SPolygon`] by replacing them with a straight edge, eliminating the vertices in between.
377pub fn close_narrow_concavities(
378    orig_shape: &SPolygon,
379    mode: ShapeModifyMode,
380    (cutoff_distance_ratio, cutoff_area_ratio): (f32, f32),
381) -> SPolygon {
382    let mut n_concav_closed = 0;
383    let mut shape = orig_shape.clone();
384
385    for _ in 0..shape.n_vertices() {
386        let n_points = shape.n_vertices();
387
388        let calc_vert_elim = |i, j| {
389            if j > i {
390                j - i - 1
391            } else {
392                n_points - i + j - 1
393            }
394        };
395
396        let mut best_candidate = None;
397        for i in 0..n_points {
398            for j in 0..n_points {
399                if i == j || (i + 1) % n_points == j || (j + 1) % n_points == i {
400                    continue; //skip adjacent points
401                }
402                //Simulate the replacing edge
403                let c_edge = Edge::try_new(shape.vertex(i), shape.vertex(j))
404                    .expect("invalid edge in string candidate")
405                    .scale(0.9999); //slightly shrink the edge to avoid self-intersections
406
407                if c_edge.length() > cutoff_distance_ratio * shape.diameter {
408                    //If the edge is too long, skip it
409                    continue;
410                }
411
412                if mode == ShapeModifyMode::Inflate
413                    && (shape.collides_with(&c_edge.start) || shape.collides_with(&c_edge.end))
414                {
415                    //If we are only allowed to inflate the shape and any end point is inside the shape, skip it
416                    continue;
417                } else if mode == ShapeModifyMode::Deflate
418                    && !(shape.collides_with(&c_edge.start) && shape.collides_with(&c_edge.end))
419                {
420                    //If we are only allowed to deflate the shape and both end points are not inside the shape, skip it
421                    continue;
422                }
423
424                if shape.edge_iter().any(|e| e.collides_with(&c_edge)) {
425                    //If the edge collides with any edge of the shape, reject always
426                    continue;
427                }
428                //the eliminated vertices should form a negative area (in inflation mode) or positive area (in deflation mode)
429                let sub_shape_area = {
430                    let sub_shape_points = if j > i {
431                        shape.vertices[i..j].to_vec()
432                    } else {
433                        [&shape.vertices[i..], &shape.vertices[..j]].concat()
434                    };
435                    SPolygon::calculate_area(&sub_shape_points)
436                };
437                if sub_shape_area >= 0.0 {
438                    //if the area is not negative, skip it
439                    continue;
440                }
441                if sub_shape_area.abs() > cutoff_area_ratio * shape.area {
442                    //if the area is too large, skip it
443                    continue;
444                }
445
446                //Valid candidate found...
447                match best_candidate {
448                    None => {
449                        //first candidate found
450                        best_candidate = Some((i, j));
451                    }
452                    Some((best_i, best_j)) => {
453                        //check the number of points that would be removed
454                        if calc_vert_elim(i, j) > calc_vert_elim(best_i, best_j) {
455                            best_candidate = Some((i, j));
456                        }
457                    }
458                }
459            }
460        }
461        if let Some((i, j)) = best_candidate {
462            let mut ref_points = shape.vertices.clone();
463            let start = i as isize + 1;
464            let end = j as isize - 1;
465            debug!(
466                "[PS] closing concavity between points (idx: {}, {:?}) and (idx: {}, {:?}) with edge length {:.3} ({} vertices eliminated)",
467                i,
468                shape.vertex(i),
469                j,
470                shape.vertex(j),
471                Edge::try_new(shape.vertex(i), shape.vertex(j))
472                    .expect("invalid edge in string candidate")
473                    .length(),
474                calc_vert_elim(i, j)
475            );
476            if start <= end {
477                // if j does not wrap around the shape
478                ref_points.drain((start as usize)..=(end as usize));
479            } else {
480                // if j wraps around the shape
481                if (start as usize) < n_points {
482                    //remove from `start` to back
483                    ref_points.drain(start as usize..);
484                }
485                if end >= 0 {
486                    //remove from front to `end`
487                    ref_points.drain(0..=(end as usize));
488                }
489            }
490            shape = SPolygon::new(ref_points).expect("invalid shape after closing concavity");
491            n_concav_closed += 1;
492        } else {
493            //no more candidates found, break the loop
494            break;
495        }
496    }
497
498    if n_concav_closed > 0 {
499        info!(
500            "[PS] [EXPERIMENTAL] closed {} concavities closer than {:.3}% of diameter and less than {:.3}% of area, reducing vertices from {} to {}",
501            n_concav_closed,
502            cutoff_distance_ratio * 100.0,
503            cutoff_area_ratio * 100.0,
504            orig_shape.n_vertices(),
505            shape.n_vertices()
506        );
507    }
508
509    shape
510}
511
512pub fn shape_modification_valid(orig: &SPolygon, simpl: &SPolygon, mode: ShapeModifyMode) -> bool {
513    //make sure each point of the original shape is either in the new shape or included (in case of inflation)/excluded (in case of deflation) in the new shape
514    let on_edge = |p: &Point| {
515        simpl
516            .edge_iter()
517            .any(|e| e.distance_to(p) < simpl.diameter * 1e-6)
518    };
519
520    for p in orig.vertices.iter().filter(|p| !simpl.vertices.contains(p)) {
521        let vertex_on_edge = on_edge(p);
522        let vertex_in_simpl = simpl.collides_with(p);
523
524        let error = match mode {
525            ShapeModifyMode::Inflate => !vertex_in_simpl && !vertex_on_edge,
526            ShapeModifyMode::Deflate => vertex_in_simpl && !vertex_on_edge,
527        };
528
529        if error {
530            error!(
531                "[PS] point {:?} from original shape is incorrect in simplified shape (original vertices: {:?}, simplified vertices: {:?})",
532                p,
533                orig.vertices.iter().map(|p| (p.0, p.1)).collect_vec(),
534                simpl.vertices.iter().map(|p| (p.0, p.1)).collect_vec()
535            );
536            return false; //point is not in the new shape and does not collide with it
537        }
538    }
539    true
540}