jagua_rs/geometry/
shape_modification.rs

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