Skip to main content

u_nesting_d2/
geometry.rs

1//! 2D geometry types.
2
3use u_nesting_core::geom::polygon as geom_polygon;
4use u_nesting_core::geometry::{Geometry, Geometry2DExt, GeometryId, RotationConstraint};
5use u_nesting_core::transform::AABB2D;
6use u_nesting_core::{Error, Result};
7
8#[cfg(feature = "serde")]
9use serde::{Deserialize, Serialize};
10
11/// A 2D polygon geometry that can be nested.
12#[derive(Debug, Clone)]
13#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
14pub struct Geometry2D {
15    /// Unique identifier.
16    id: GeometryId,
17
18    /// Outer boundary of the polygon.
19    exterior: Vec<(f64, f64)>,
20
21    /// Interior holes (if any).
22    holes: Vec<Vec<(f64, f64)>>,
23
24    /// Number of copies to place.
25    quantity: usize,
26
27    /// Rotation constraint.
28    rotation_constraint: RotationConstraint<f64>,
29
30    /// Whether the geometry can be flipped (mirrored).
31    allow_flip: bool,
32
33    /// Placement priority (higher = placed first).
34    priority: i32,
35
36    /// Cached area.
37    #[cfg_attr(feature = "serde", serde(skip))]
38    cached_area: Option<f64>,
39
40    /// Cached convex hull.
41    #[cfg_attr(feature = "serde", serde(skip))]
42    cached_convex_hull: Option<Vec<(f64, f64)>>,
43
44    /// Cached perimeter.
45    #[cfg_attr(feature = "serde", serde(skip))]
46    cached_perimeter: Option<f64>,
47
48    /// Cached convexity flag.
49    #[cfg_attr(feature = "serde", serde(skip))]
50    cached_is_convex: Option<bool>,
51}
52
53impl Geometry2D {
54    /// Creates a new 2D geometry with the given ID.
55    pub fn new(id: impl Into<GeometryId>) -> Self {
56        Self {
57            id: id.into(),
58            exterior: Vec::new(),
59            holes: Vec::new(),
60            quantity: 1,
61            rotation_constraint: RotationConstraint::None,
62            allow_flip: false,
63            priority: 0,
64            cached_area: None,
65            cached_convex_hull: None,
66            cached_perimeter: None,
67            cached_is_convex: None,
68        }
69    }
70
71    /// Sets the polygon from a list of (x, y) vertices.
72    pub fn with_polygon(mut self, vertices: Vec<(f64, f64)>) -> Self {
73        self.exterior = vertices;
74        self.clear_cache();
75        self
76    }
77
78    /// Adds an interior hole.
79    pub fn with_hole(mut self, vertices: Vec<(f64, f64)>) -> Self {
80        self.holes.push(vertices);
81        self.clear_cache();
82        self
83    }
84
85    /// Sets the quantity to place.
86    pub fn with_quantity(mut self, n: usize) -> Self {
87        self.quantity = n;
88        self
89    }
90
91    /// Sets the allowed rotation angles in degrees.
92    pub fn with_rotations_deg(mut self, angles: Vec<f64>) -> Self {
93        let radians: Vec<f64> = angles.into_iter().map(|a| a.to_radians()).collect();
94        self.rotation_constraint = RotationConstraint::Discrete(radians);
95        self
96    }
97
98    /// Sets the allowed rotation angles in radians.
99    pub fn with_rotations(mut self, angles: Vec<f64>) -> Self {
100        self.rotation_constraint = RotationConstraint::Discrete(angles);
101        self
102    }
103
104    /// Sets the rotation constraint.
105    pub fn with_rotation_constraint(mut self, constraint: RotationConstraint<f64>) -> Self {
106        self.rotation_constraint = constraint;
107        self
108    }
109
110    /// Allows flipping (mirroring) the geometry.
111    pub fn with_flip(mut self, allow: bool) -> Self {
112        self.allow_flip = allow;
113        self
114    }
115
116    /// Sets the placement priority.
117    pub fn with_priority(mut self, priority: i32) -> Self {
118        self.priority = priority;
119        self
120    }
121
122    /// Creates a rectangular geometry.
123    pub fn rectangle(id: impl Into<GeometryId>, width: f64, height: f64) -> Self {
124        Self::new(id).with_polygon(vec![
125            (0.0, 0.0),
126            (width, 0.0),
127            (width, height),
128            (0.0, height),
129        ])
130    }
131
132    /// Creates a circle approximation with n vertices.
133    pub fn circle(id: impl Into<GeometryId>, radius: f64, n: usize) -> Self {
134        let n = n.max(8);
135        let step = std::f64::consts::TAU / n as f64;
136        let vertices: Vec<(f64, f64)> = (0..n)
137            .map(|i| {
138                let angle = i as f64 * step;
139                (radius * angle.cos() + radius, radius * angle.sin() + radius)
140            })
141            .collect();
142        Self::new(id).with_polygon(vertices)
143    }
144
145    /// Creates an L-shaped geometry.
146    pub fn l_shape(
147        id: impl Into<GeometryId>,
148        width: f64,
149        height: f64,
150        notch_width: f64,
151        notch_height: f64,
152    ) -> Self {
153        Self::new(id).with_polygon(vec![
154            (0.0, 0.0),
155            (width, 0.0),
156            (width, notch_height),
157            (notch_width, notch_height),
158            (notch_width, height),
159            (0.0, height),
160        ])
161    }
162
163    /// Returns the exterior vertices.
164    pub fn exterior(&self) -> &[(f64, f64)] {
165        &self.exterior
166    }
167
168    /// Returns the allowed rotation angles (for compatibility).
169    pub fn rotations(&self) -> Vec<f64> {
170        self.rotation_constraint.angles()
171    }
172
173    /// Returns whether flipping is allowed.
174    pub fn allow_flip(&self) -> bool {
175        self.allow_flip
176    }
177
178    /// Clears all cached values.
179    fn clear_cache(&mut self) {
180        self.cached_area = None;
181        self.cached_convex_hull = None;
182        self.cached_perimeter = None;
183        self.cached_is_convex = None;
184    }
185
186    /// Calculates the area of the polygon (exterior minus holes).
187    fn calculate_area(&self) -> f64 {
188        let hole_refs: Vec<&[(f64, f64)]> = self.holes.iter().map(|h| h.as_slice()).collect();
189        geom_polygon::area_with_holes(&self.exterior, &hole_refs)
190    }
191
192    /// Calculates the perimeter of the polygon.
193    fn calculate_perimeter(&self) -> f64 {
194        let mut perim = geom_polygon::perimeter(&self.exterior);
195        for hole in &self.holes {
196            perim += geom_polygon::perimeter(hole);
197        }
198        perim
199    }
200
201    /// Calculates the convex hull.
202    fn calculate_convex_hull(&self) -> Vec<(f64, f64)> {
203        geom_polygon::convex_hull(&self.exterior)
204    }
205
206    /// Checks if the polygon is convex.
207    fn calculate_is_convex(&self) -> bool {
208        if self.exterior.len() < 3 || !self.holes.is_empty() {
209            return false;
210        }
211
212        // A polygon is convex if all cross products of consecutive edge pairs
213        // have the same sign
214        let n = self.exterior.len();
215        let mut sign = 0i32;
216
217        for i in 0..n {
218            let (x1, y1) = self.exterior[i];
219            let (x2, y2) = self.exterior[(i + 1) % n];
220            let (x3, y3) = self.exterior[(i + 2) % n];
221
222            let cross = (x2 - x1) * (y3 - y2) - (y2 - y1) * (x3 - x2);
223
224            if cross.abs() > 1e-10 {
225                let current_sign = if cross > 0.0 { 1 } else { -1 };
226                if sign == 0 {
227                    sign = current_sign;
228                } else if sign != current_sign {
229                    return false;
230                }
231            }
232        }
233
234        true
235    }
236}
237
238impl Geometry for Geometry2D {
239    type Scalar = f64;
240
241    fn id(&self) -> &GeometryId {
242        &self.id
243    }
244
245    fn quantity(&self) -> usize {
246        self.quantity
247    }
248
249    fn measure(&self) -> f64 {
250        if let Some(area) = self.cached_area {
251            area
252        } else {
253            self.calculate_area()
254        }
255    }
256
257    fn aabb(&self) -> ([f64; 2], [f64; 2]) {
258        let (min, max) = self.aabb_vec();
259        ([min[0], min[1]], [max[0], max[1]])
260    }
261
262    fn aabb_vec(&self) -> (Vec<f64>, Vec<f64>) {
263        if self.exterior.is_empty() {
264            return (vec![0.0, 0.0], vec![0.0, 0.0]);
265        }
266
267        let mut min_x = f64::MAX;
268        let mut min_y = f64::MAX;
269        let mut max_x = f64::MIN;
270        let mut max_y = f64::MIN;
271
272        for &(x, y) in &self.exterior {
273            min_x = min_x.min(x);
274            min_y = min_y.min(y);
275            max_x = max_x.max(x);
276            max_y = max_y.max(y);
277        }
278
279        (vec![min_x, min_y], vec![max_x, max_y])
280    }
281
282    fn centroid(&self) -> Vec<f64> {
283        let hole_refs: Vec<&[(f64, f64)]> = self.holes.iter().map(|h| h.as_slice()).collect();
284        if let Some((cx, cy)) = geom_polygon::centroid_with_holes(&self.exterior, &hole_refs) {
285            vec![cx, cy]
286        } else {
287            vec![0.0, 0.0]
288        }
289    }
290
291    fn validate(&self) -> Result<()> {
292        if self.exterior.len() < 3 {
293            return Err(Error::InvalidGeometry(format!(
294                "Polygon '{}' must have at least 3 vertices",
295                self.id
296            )));
297        }
298
299        if self.quantity == 0 {
300            return Err(Error::InvalidGeometry(format!(
301                "Quantity for '{}' must be at least 1",
302                self.id
303            )));
304        }
305
306        // Mirroring is unimplemented (the placement pipeline never applies a
307        // reflection), so silently accepting `allow_flip` would misreport what
308        // was solved. Reject it explicitly rather than ignore it.
309        if self.allow_flip {
310            return Err(Error::InvalidGeometry(format!(
311                "Polygon '{}': allow_flip is not supported (mirroring is unimplemented); \
312                 set allow_flip to false",
313                self.id
314            )));
315        }
316
317        // Reject degenerate (zero-area / collinear) polygons: they make NFP and
318        // collision tests meaningless. Threshold scales with extent so a small
319        // but legitimate piece survives while a truly collinear ring is caught.
320        let (min, max) = self.aabb_vec();
321        let scale = (max[0] - min[0]).max(max[1] - min[1]).max(1.0);
322        let area_eps = 1e-9 * scale * scale;
323        if geom_polygon::signed_area(&self.exterior).abs() < area_eps {
324            return Err(Error::InvalidGeometry(format!(
325                "Polygon '{}' is degenerate (zero area / collinear vertices)",
326                self.id
327            )));
328        }
329
330        // Reject self-intersecting exteriors (e.g. a bow-tie): a non-simple
331        // outline breaks point-in-polygon and NFP, which can produce overlapping
332        // placements downstream.
333        if !crate::polygon_ops::is_simple_polygon(&self.exterior) {
334            return Err(Error::InvalidGeometry(format!(
335                "Polygon '{}' is self-intersecting (edges cross)",
336                self.id
337            )));
338        }
339
340        Ok(())
341    }
342
343    fn rotation_constraint(&self) -> &RotationConstraint<f64> {
344        &self.rotation_constraint
345    }
346
347    fn allow_mirror(&self) -> bool {
348        self.allow_flip
349    }
350
351    fn priority(&self) -> i32 {
352        self.priority
353    }
354}
355
356impl Geometry2DExt for Geometry2D {
357    fn aabb_2d(&self) -> AABB2D<f64> {
358        let (min, max) = self.aabb_vec();
359        AABB2D::new(min[0], min[1], max[0], max[1])
360    }
361
362    fn outer_ring(&self) -> &[(f64, f64)] {
363        &self.exterior
364    }
365
366    fn holes(&self) -> &[Vec<(f64, f64)>] {
367        &self.holes
368    }
369
370    fn is_convex(&self) -> bool {
371        if let Some(is_convex) = self.cached_is_convex {
372            is_convex
373        } else {
374            self.calculate_is_convex()
375        }
376    }
377
378    fn convex_hull(&self) -> Vec<(f64, f64)> {
379        if let Some(ref hull) = self.cached_convex_hull {
380            hull.clone()
381        } else {
382            self.calculate_convex_hull()
383        }
384    }
385
386    fn perimeter(&self) -> f64 {
387        if let Some(perim) = self.cached_perimeter {
388            perim
389        } else {
390            self.calculate_perimeter()
391        }
392    }
393}
394
395impl Geometry2D {
396    /// Computes the AABB of the geometry at a given rotation angle (in radians).
397    ///
398    /// Returns (min, max) as ([min_x, min_y], [max_x, max_y])
399    pub fn aabb_at_rotation(&self, rotation: f64) -> ([f64; 2], [f64; 2]) {
400        if rotation.abs() < 1e-10 {
401            return self.aabb();
402        }
403
404        let cos_r = rotation.cos();
405        let sin_r = rotation.sin();
406
407        let mut min_x = f64::INFINITY;
408        let mut min_y = f64::INFINITY;
409        let mut max_x = f64::NEG_INFINITY;
410        let mut max_y = f64::NEG_INFINITY;
411
412        for &(x, y) in &self.exterior {
413            let rx = x * cos_r - y * sin_r;
414            let ry = x * sin_r + y * cos_r;
415            min_x = min_x.min(rx);
416            min_y = min_y.min(ry);
417            max_x = max_x.max(rx);
418            max_y = max_y.max(ry);
419        }
420
421        ([min_x, min_y], [max_x, max_y])
422    }
423
424    /// Returns the width and height of the AABB at a given rotation.
425    pub fn dimensions_at_rotation(&self, rotation: f64) -> (f64, f64) {
426        let (min, max) = self.aabb_at_rotation(rotation);
427        (max[0] - min[0], max[1] - min[1])
428    }
429
430    /// Returns the exterior ring rotated by `rotation` (radians, CCW about the
431    /// origin) and translated to placement position `(x, y)`.
432    ///
433    /// This is the piece's actual footprint on the sheet — the same transform
434    /// used by [`Self::aabb_at_rotation`], made concrete per vertex so callers
435    /// can perform exact polygon-in-polygon containment against a non-rectangular
436    /// boundary (AABB containment is only exact for axis-aligned rectangles).
437    pub fn transformed_exterior(&self, x: f64, y: f64, rotation: f64) -> Vec<(f64, f64)> {
438        let cos_r = rotation.cos();
439        let sin_r = rotation.sin();
440        self.exterior
441            .iter()
442            .map(|&(vx, vy)| {
443                let rx = vx * cos_r - vy * sin_r;
444                let ry = vx * sin_r + vy * cos_r;
445                (x + rx, y + ry)
446            })
447            .collect()
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use approx::assert_relative_eq;
455
456    #[test]
457    fn test_rectangle_area() {
458        let rect = Geometry2D::rectangle("R1", 10.0, 5.0);
459        assert_relative_eq!(rect.measure(), 50.0, epsilon = 0.001);
460    }
461
462    #[test]
463    fn test_polygon_with_hole() {
464        let poly = Geometry2D::new("P1")
465            .with_polygon(vec![(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)])
466            .with_hole(vec![(25.0, 25.0), (75.0, 25.0), (75.0, 75.0), (25.0, 75.0)]);
467
468        // Area = 100*100 - 50*50 = 10000 - 2500 = 7500
469        assert_relative_eq!(poly.measure(), 7500.0, epsilon = 0.001);
470    }
471
472    #[test]
473    fn test_aabb() {
474        let poly = Geometry2D::new("P1").with_polygon(vec![
475            (10.0, 20.0),
476            (50.0, 20.0),
477            (50.0, 80.0),
478            (10.0, 80.0),
479        ]);
480
481        let aabb = poly.aabb_2d();
482        assert_relative_eq!(aabb.min_x, 10.0);
483        assert_relative_eq!(aabb.min_y, 20.0);
484        assert_relative_eq!(aabb.max_x, 50.0);
485        assert_relative_eq!(aabb.max_y, 80.0);
486    }
487
488    #[test]
489    fn test_rectangle_is_convex() {
490        let rect = Geometry2D::rectangle("R1", 10.0, 10.0);
491        assert!(rect.is_convex());
492    }
493
494    #[test]
495    fn test_l_shape_is_not_convex() {
496        let l = Geometry2D::l_shape("L1", 20.0, 20.0, 10.0, 10.0);
497        assert!(!l.is_convex());
498    }
499
500    #[test]
501    fn test_convex_hull() {
502        let l = Geometry2D::l_shape("L1", 20.0, 20.0, 10.0, 10.0);
503        let hull = l.convex_hull();
504        // Convex hull of L-shape should be a quadrilateral
505        assert!(hull.len() >= 4);
506    }
507
508    #[test]
509    fn test_centroid() {
510        let rect = Geometry2D::rectangle("R1", 10.0, 10.0);
511        let centroid = rect.centroid();
512        assert_relative_eq!(centroid[0], 5.0, epsilon = 0.001);
513        assert_relative_eq!(centroid[1], 5.0, epsilon = 0.001);
514    }
515
516    #[test]
517    fn test_perimeter() {
518        let rect = Geometry2D::rectangle("R1", 10.0, 5.0);
519        assert_relative_eq!(rect.perimeter(), 30.0, epsilon = 0.001);
520    }
521
522    #[test]
523    fn test_validation() {
524        let valid = Geometry2D::rectangle("R1", 10.0, 10.0);
525        assert!(valid.validate().is_ok());
526
527        let invalid = Geometry2D::new("P1").with_polygon(vec![(0.0, 0.0), (1.0, 0.0)]);
528        assert!(invalid.validate().is_err());
529    }
530
531    #[test]
532    fn test_circle() {
533        let circle = Geometry2D::circle("C1", 10.0, 32);
534        let area = circle.measure();
535        let expected = std::f64::consts::PI * 10.0 * 10.0;
536        // Circle approximation should be close to actual area
537        assert_relative_eq!(area, expected, epsilon = 5.0);
538    }
539}