Skip to main content

jagua_rs/geometry/
shape_modification.rs

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