Skip to main content

brepkit_math/
polygon_offset.rs

1//! 2D polygon offset via parallel edge translation and miter joins.
2//!
3//! Given a closed polygon in 2D, offsets each edge by a signed distance
4//! (positive = outward, negative = inward) and computes new vertex
5//! positions at the intersection of adjacent offset lines.
6
7use crate::MathError;
8use crate::vec::Point2;
9
10/// Default miter limit: if a miter join extends further than this factor
11/// times the offset distance, it is clamped to a bevel join.
12const DEFAULT_MITER_LIMIT: f64 = 2.0;
13
14/// Offset a closed 2D polygon by a signed distance.
15///
16/// Positive `distance` offsets outward (assuming CCW winding),
17/// negative offsets inward. Applies a miter limit of 2× the offset
18/// distance to prevent spikes at sharp corners, and removes
19/// self-intersections from the result.
20///
21/// # Algorithm
22///
23/// For each edge, compute a parallel offset line by translating along
24/// the outward normal. Intersect adjacent offset lines to find new
25/// vertex positions (miter join). Degenerate intersections (parallel
26/// edges) fall back to simple translation.
27///
28/// # Errors
29///
30/// Returns an error if fewer than 3 vertices are provided.
31pub fn offset_polygon_2d(
32    vertices: &[Point2],
33    distance: f64,
34    tolerance: f64,
35) -> Result<Vec<Point2>, MathError> {
36    let n = vertices.len();
37    if n < 3 {
38        return Err(MathError::EmptyInput);
39    }
40
41    let miter_limit_dist = distance.abs() * DEFAULT_MITER_LIMIT;
42
43    // The outward-normal convention below assumes CCW winding. For a CW
44    // loop the same perpendicular points inward, inverting the sign of
45    // `distance`. Detect the actual winding via the signed area and flip
46    // the effective distance so that negative always shrinks the loop.
47    let area2 = signed_area_2x(vertices);
48    if area2.abs() < tolerance {
49        return Err(MathError::EmptyInput);
50    }
51    let distance = if area2 < 0.0 { -distance } else { distance };
52
53    // Compute offset edge lines: each edge shifts by distance along its outward normal.
54    // For a CCW polygon, the outward normal of edge (A→B) is (dy, -dx) normalized.
55    let mut result = Vec::with_capacity(n);
56
57    for i in 0..n {
58        let prev = (i + n - 1) % n;
59        let next = (i + 1) % n;
60
61        // Previous edge: prev → i
62        let (nx0, ny0) = edge_outward_normal(vertices[prev], vertices[i]);
63        // Current edge: i → next
64        let (nx1, ny1) = edge_outward_normal(vertices[i], vertices[next]);
65
66        // Offset lines:
67        // Line 0 passes through (vertices[prev] + d*n0) with direction (vertices[i] - vertices[prev])
68        // Line 1 passes through (vertices[i] + d*n1) with direction (vertices[next] - vertices[i])
69        let p0 = Point2::new(
70            vertices[i].x() + distance * nx0,
71            vertices[i].y() + distance * ny0,
72        );
73        let p1 = Point2::new(
74            vertices[i].x() + distance * nx1,
75            vertices[i].y() + distance * ny1,
76        );
77
78        let d0x = vertices[i].x() - vertices[prev].x();
79        let d0y = vertices[i].y() - vertices[prev].y();
80        let d1x = vertices[next].x() - vertices[i].x();
81        let d1y = vertices[next].y() - vertices[i].y();
82
83        // Intersect two lines: p0 + t*d0 = p1 + s*d1
84        // Cross product for 2D line intersection
85        let cross = d0x * d1y - d0y * d1x;
86
87        if cross.abs() < tolerance {
88            // Parallel edges — just translate the vertex
89            let avg_nx = (nx0 + nx1) * 0.5;
90            let avg_ny = (ny0 + ny1) * 0.5;
91            result.push(Point2::new(
92                vertices[i].x() + distance * avg_nx,
93                vertices[i].y() + distance * avg_ny,
94            ));
95        } else {
96            // Solve for t: (p1 - p0) × d1 / (d0 × d1)
97            let dx = p1.x() - p0.x();
98            let dy = p1.y() - p0.y();
99            let t = (dx * d1y - dy * d1x) / cross;
100            let miter_pt = Point2::new(p0.x() + t * d0x, p0.y() + t * d0y);
101
102            // Miter limit: if the miter point is too far from the original
103            // vertex, clamp to prevent spikes at near-parallel edges.
104            let miter_dist = ((miter_pt.x() - vertices[i].x()).powi(2)
105                + (miter_pt.y() - vertices[i].y()).powi(2))
106            .sqrt();
107
108            if miter_dist > miter_limit_dist && miter_limit_dist > tolerance {
109                // Bevel: use the average of the two offset points.
110                result.push(Point2::new(
111                    (p0.x() + p1.x()) * 0.5,
112                    (p0.y() + p1.y()) * 0.5,
113                ));
114            } else {
115                result.push(miter_pt);
116            }
117        }
118    }
119
120    // Remove self-intersections by detecting and clipping crossing edges.
121    remove_self_intersections(&mut result, tolerance);
122
123    Ok(result)
124}
125
126/// Remove self-intersections from a polygon by detecting crossing edges
127/// and keeping only the largest non-self-intersecting loop.
128fn remove_self_intersections(polygon: &mut Vec<Point2>, tolerance: f64) {
129    if polygon.len() < 4 {
130        return;
131    }
132
133    let n = polygon.len();
134    let tol_sq = tolerance * tolerance;
135
136    // Check all non-adjacent edge pairs for intersections.
137    // When found, remove the smaller loop by cutting out the vertices between
138    // the crossing edges.
139    let mut i = 0;
140    while i < polygon.len().saturating_sub(2) {
141        let n_cur = polygon.len();
142        let a1 = polygon[i];
143        let a2 = polygon[(i + 1) % n_cur];
144        let mut found = false;
145
146        // Only check edges that are at least 2 apart (non-adjacent).
147        let mut j = i + 2;
148        while j < n_cur {
149            // Skip the last edge wrapping back to edge 0 if i == 0.
150            if i == 0 && j == n_cur - 1 {
151                j += 1;
152                continue;
153            }
154
155            let b1 = polygon[j];
156            let b2 = polygon[(j + 1) % n_cur];
157
158            if let Some(_pt) = segment_intersection_2d(a1, a2, b1, b2, tol_sq) {
159                // Self-intersection found between edges i and j.
160                // Remove the shorter loop (vertices between i+1 and j).
161                let loop_len = j - i - 1;
162                let other_len = n_cur - loop_len;
163
164                if loop_len <= other_len {
165                    // Remove vertices i+1..j
166                    polygon.drain((i + 1)..j);
167                } else {
168                    // Remove vertices j+1..end and 0..i
169                    let kept: Vec<Point2> = polygon[i..=j].to_vec();
170                    *polygon = kept;
171                }
172                found = true;
173                break;
174            }
175            j += 1;
176        }
177
178        if !found {
179            i += 1;
180        }
181        // If found, restart from same i since polygon was modified.
182
183        // Safety: prevent infinite loop if polygon degenerates.
184        if polygon.len() < 3 || polygon.len() > n * 2 {
185            break;
186        }
187    }
188}
189
190/// Compute the intersection point of two 2D line segments, if any.
191fn segment_intersection_2d(
192    a1: Point2,
193    a2: Point2,
194    b1: Point2,
195    b2: Point2,
196    _tol_sq: f64,
197) -> Option<Point2> {
198    let dx_a = a2.x() - a1.x();
199    let dy_a = a2.y() - a1.y();
200    let dx_b = b2.x() - b1.x();
201    let dy_b = b2.y() - b1.y();
202
203    let denom = dx_a * dy_b - dy_a * dx_b;
204    if denom.abs() < 1e-15 {
205        return None; // Parallel
206    }
207
208    let dx_ab = b1.x() - a1.x();
209    let dy_ab = b1.y() - a1.y();
210    let t = (dx_ab * dy_b - dy_ab * dx_b) / denom;
211    let s = (dx_ab * dy_a - dy_ab * dx_a) / denom;
212
213    // Both parameters must be in (0, 1) (exclusive to avoid endpoint touches).
214    let eps = 1e-10;
215    if t > eps && t < 1.0 - eps && s > eps && s < 1.0 - eps {
216        Some(Point2::new(a1.x() + t * dx_a, a1.y() + t * dy_a))
217    } else {
218        None
219    }
220}
221
222/// Twice the signed area of a closed 2D polygon (shoelace formula).
223///
224/// Positive for CCW winding, negative for CW.
225fn signed_area_2x(vertices: &[Point2]) -> f64 {
226    let n = vertices.len();
227    let mut acc = 0.0;
228    for i in 0..n {
229        let j = (i + 1) % n;
230        acc += vertices[i].x() * vertices[j].y() - vertices[j].x() * vertices[i].y();
231    }
232    acc
233}
234
235/// Compute the outward normal of a 2D edge (assuming CCW winding).
236///
237/// For edge A→B, the outward normal is the left-perpendicular of (B-A),
238/// normalized to unit length. Returns `(0, 0)` for degenerate edges.
239fn edge_outward_normal(a: Point2, b: Point2) -> (f64, f64) {
240    let dx = b.x() - a.x();
241    let dy = b.y() - a.y();
242    let len = (dx * dx + dy * dy).sqrt();
243    if len < 1e-15 {
244        return (0.0, 0.0);
245    }
246    // Left perpendicular = (dy, -dx) / len
247    (dy / len, -dx / len)
248}
249
250#[cfg(test)]
251mod tests {
252    #![allow(clippy::unwrap_used)]
253
254    use super::*;
255
256    #[test]
257    fn offset_square_outward() {
258        let square = vec![
259            Point2::new(0.0, 0.0),
260            Point2::new(1.0, 0.0),
261            Point2::new(1.0, 1.0),
262            Point2::new(0.0, 1.0),
263        ];
264        let result = offset_polygon_2d(&square, 0.5, 1e-10).unwrap();
265        assert_eq!(result.len(), 4);
266        // Outward offset of unit square by 0.5 → vertices at (-0.5, -0.5), (1.5, -0.5), etc.
267        assert!((result[0].x() - (-0.5)).abs() < 1e-10);
268        assert!((result[0].y() - (-0.5)).abs() < 1e-10);
269        assert!((result[1].x() - 1.5).abs() < 1e-10);
270        assert!((result[1].y() - (-0.5)).abs() < 1e-10);
271        assert!((result[2].x() - 1.5).abs() < 1e-10);
272        assert!((result[2].y() - 1.5).abs() < 1e-10);
273        assert!((result[3].x() - (-0.5)).abs() < 1e-10);
274        assert!((result[3].y() - 1.5).abs() < 1e-10);
275    }
276
277    #[test]
278    fn offset_square_inward() {
279        let square = vec![
280            Point2::new(0.0, 0.0),
281            Point2::new(2.0, 0.0),
282            Point2::new(2.0, 2.0),
283            Point2::new(0.0, 2.0),
284        ];
285        let result = offset_polygon_2d(&square, -0.5, 1e-10).unwrap();
286        assert_eq!(result.len(), 4);
287        assert!((result[0].x() - 0.5).abs() < 1e-10);
288        assert!((result[0].y() - 0.5).abs() < 1e-10);
289        assert!((result[1].x() - 1.5).abs() < 1e-10);
290        assert!((result[1].y() - 0.5).abs() < 1e-10);
291    }
292
293    #[test]
294    fn offset_triangle() {
295        let tri = vec![
296            Point2::new(0.0, 0.0),
297            Point2::new(4.0, 0.0),
298            Point2::new(2.0, 3.0),
299        ];
300        let result = offset_polygon_2d(&tri, 0.1, 1e-10).unwrap();
301        assert_eq!(result.len(), 3);
302        // All vertices should be further from the centroid
303        let cx = (tri[0].x() + tri[1].x() + tri[2].x()) / 3.0;
304        let cy = (tri[0].y() + tri[1].y() + tri[2].y()) / 3.0;
305        for (orig, off) in tri.iter().zip(result.iter()) {
306            let d_orig = ((orig.x() - cx).powi(2) + (orig.y() - cy).powi(2)).sqrt();
307            let d_off = ((off.x() - cx).powi(2) + (off.y() - cy).powi(2)).sqrt();
308            assert!(
309                d_off > d_orig,
310                "offset vertex should be farther from centroid"
311            );
312        }
313    }
314
315    #[test]
316    fn too_few_vertices() {
317        let pts = vec![Point2::new(0.0, 0.0), Point2::new(1.0, 0.0)];
318        assert!(offset_polygon_2d(&pts, 0.1, 1e-10).is_err());
319    }
320
321    fn signed_area(poly: &[Point2]) -> f64 {
322        let n = poly.len();
323        let mut acc = 0.0;
324        for i in 0..n {
325            let j = (i + 1) % n;
326            acc += poly[i].x() * poly[j].y() - poly[j].x() * poly[i].y();
327        }
328        acc * 0.5
329    }
330
331    #[test]
332    fn offset_cw_square_inward() {
333        // Clockwise-wound 20x20 square. A negative distance must shrink it
334        // (area 256), regardless of winding.
335        let cw_square = vec![
336            Point2::new(20.0, 0.0),
337            Point2::new(0.0, 0.0),
338            Point2::new(0.0, 20.0),
339            Point2::new(20.0, 20.0),
340        ];
341        let inward = offset_polygon_2d(&cw_square, -2.0, 1e-10).unwrap();
342        assert_eq!(inward.len(), 4);
343        assert!(
344            (signed_area(&inward).abs() - 256.0).abs() < 1e-6,
345            "CW square offset -2 should enclose area 256, got {}",
346            signed_area(&inward).abs()
347        );
348
349        let outward = offset_polygon_2d(&cw_square, 2.0, 1e-10).unwrap();
350        assert_eq!(outward.len(), 4);
351        assert!(
352            (signed_area(&outward).abs() - 576.0).abs() < 1e-6,
353            "CW square offset +2 should enclose area 576, got {}",
354            signed_area(&outward).abs()
355        );
356    }
357}