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        // Reject degenerate (zero-area / collinear) polygons: they make NFP and
307        // collision tests meaningless. Threshold scales with extent so a small
308        // but legitimate piece survives while a truly collinear ring is caught.
309        let (min, max) = self.aabb_vec();
310        let scale = (max[0] - min[0]).max(max[1] - min[1]).max(1.0);
311        let area_eps = 1e-9 * scale * scale;
312        if geom_polygon::signed_area(&self.exterior).abs() < area_eps {
313            return Err(Error::InvalidGeometry(format!(
314                "Polygon '{}' is degenerate (zero area / collinear vertices)",
315                self.id
316            )));
317        }
318
319        // Reject self-intersecting exteriors (e.g. a bow-tie): a non-simple
320        // outline breaks point-in-polygon and NFP, which can produce overlapping
321        // placements downstream.
322        if !crate::polygon_ops::is_simple_polygon(&self.exterior) {
323            return Err(Error::InvalidGeometry(format!(
324                "Polygon '{}' is self-intersecting (edges cross)",
325                self.id
326            )));
327        }
328
329        Ok(())
330    }
331
332    fn rotation_constraint(&self) -> &RotationConstraint<f64> {
333        &self.rotation_constraint
334    }
335
336    fn allow_mirror(&self) -> bool {
337        self.allow_flip
338    }
339
340    fn priority(&self) -> i32 {
341        self.priority
342    }
343}
344
345impl Geometry2DExt for Geometry2D {
346    fn aabb_2d(&self) -> AABB2D<f64> {
347        let (min, max) = self.aabb_vec();
348        AABB2D::new(min[0], min[1], max[0], max[1])
349    }
350
351    fn outer_ring(&self) -> &[(f64, f64)] {
352        &self.exterior
353    }
354
355    fn holes(&self) -> &[Vec<(f64, f64)>] {
356        &self.holes
357    }
358
359    fn is_convex(&self) -> bool {
360        if let Some(is_convex) = self.cached_is_convex {
361            is_convex
362        } else {
363            self.calculate_is_convex()
364        }
365    }
366
367    fn convex_hull(&self) -> Vec<(f64, f64)> {
368        if let Some(ref hull) = self.cached_convex_hull {
369            hull.clone()
370        } else {
371            self.calculate_convex_hull()
372        }
373    }
374
375    fn perimeter(&self) -> f64 {
376        if let Some(perim) = self.cached_perimeter {
377            perim
378        } else {
379            self.calculate_perimeter()
380        }
381    }
382}
383
384impl Geometry2D {
385    /// Computes the AABB of the geometry at a given rotation angle (in radians).
386    ///
387    /// Returns (min, max) as ([min_x, min_y], [max_x, max_y])
388    pub fn aabb_at_rotation(&self, rotation: f64) -> ([f64; 2], [f64; 2]) {
389        if rotation.abs() < 1e-10 {
390            return self.aabb();
391        }
392
393        let cos_r = rotation.cos();
394        let sin_r = rotation.sin();
395
396        let mut min_x = f64::INFINITY;
397        let mut min_y = f64::INFINITY;
398        let mut max_x = f64::NEG_INFINITY;
399        let mut max_y = f64::NEG_INFINITY;
400
401        for &(x, y) in &self.exterior {
402            let rx = x * cos_r - y * sin_r;
403            let ry = x * sin_r + y * cos_r;
404            min_x = min_x.min(rx);
405            min_y = min_y.min(ry);
406            max_x = max_x.max(rx);
407            max_y = max_y.max(ry);
408        }
409
410        ([min_x, min_y], [max_x, max_y])
411    }
412
413    /// Same as [`Self::aabb_at_rotation`], but the geometry can be mirrored
414    /// first (`allow_flip` support) — mirror-then-rotate, matching the order
415    /// every other mirror-aware transform in this crate uses
416    /// ([`crate::polygon_ops::mirror_polygon`],
417    /// [`crate::nfp::compute_nfp_mirrored`],
418    /// [`crate::nfp::PlacedGeometry::translated_exterior`]).
419    ///
420    /// A reflection preserves AABB *width*/*height* (mirroring negates local
421    /// x, which swaps min/max but not their difference), but **not** the
422    /// extents' offsets from the local origin when the geometry isn't
423    /// symmetric about it — `aabb_at_rotation(rotation)` alone silently
424    /// reuses the unmirrored offsets for a mirrored placement, which is
425    /// exactly wrong for the boundary-clamp step every mirror-supporting
426    /// strategy runs after picking a candidate.
427    pub fn aabb_at_rotation_mirrored(&self, rotation: f64, mirror: bool) -> ([f64; 2], [f64; 2]) {
428        if !mirror {
429            return self.aabb_at_rotation(rotation);
430        }
431
432        let cos_r = rotation.cos();
433        let sin_r = rotation.sin();
434
435        let mut min_x = f64::INFINITY;
436        let mut min_y = f64::INFINITY;
437        let mut max_x = f64::NEG_INFINITY;
438        let mut max_y = f64::NEG_INFINITY;
439
440        for &(x, y) in &self.exterior {
441            let mx = -x; // mirror across the y-axis first
442            let rx = mx * cos_r - y * sin_r;
443            let ry = mx * sin_r + y * cos_r;
444            min_x = min_x.min(rx);
445            min_y = min_y.min(ry);
446            max_x = max_x.max(rx);
447            max_y = max_y.max(ry);
448        }
449
450        ([min_x, min_y], [max_x, max_y])
451    }
452
453    /// Returns the width and height of the AABB at a given rotation.
454    pub fn dimensions_at_rotation(&self, rotation: f64) -> (f64, f64) {
455        let (min, max) = self.aabb_at_rotation(rotation);
456        (max[0] - min[0], max[1] - min[1])
457    }
458
459    /// Returns the exterior ring rotated by `rotation` (radians, CCW about the
460    /// origin) and translated to placement position `(x, y)`.
461    ///
462    /// This is the piece's actual footprint on the sheet — the same transform
463    /// used by [`Self::aabb_at_rotation`], made concrete per vertex so callers
464    /// can perform exact polygon-in-polygon containment against a non-rectangular
465    /// boundary (AABB containment is only exact for axis-aligned rectangles).
466    pub fn transformed_exterior(&self, x: f64, y: f64, rotation: f64) -> Vec<(f64, f64)> {
467        let cos_r = rotation.cos();
468        let sin_r = rotation.sin();
469        self.exterior
470            .iter()
471            .map(|&(vx, vy)| {
472                let rx = vx * cos_r - vy * sin_r;
473                let ry = vx * sin_r + vy * cos_r;
474                (x + rx, y + ry)
475            })
476            .collect()
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use approx::assert_relative_eq;
484
485    #[test]
486    fn test_rectangle_area() {
487        let rect = Geometry2D::rectangle("R1", 10.0, 5.0);
488        assert_relative_eq!(rect.measure(), 50.0, epsilon = 0.001);
489    }
490
491    #[test]
492    fn test_polygon_with_hole() {
493        let poly = Geometry2D::new("P1")
494            .with_polygon(vec![(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)])
495            .with_hole(vec![(25.0, 25.0), (75.0, 25.0), (75.0, 75.0), (25.0, 75.0)]);
496
497        // Area = 100*100 - 50*50 = 10000 - 2500 = 7500
498        assert_relative_eq!(poly.measure(), 7500.0, epsilon = 0.001);
499    }
500
501    #[test]
502    fn test_aabb() {
503        let poly = Geometry2D::new("P1").with_polygon(vec![
504            (10.0, 20.0),
505            (50.0, 20.0),
506            (50.0, 80.0),
507            (10.0, 80.0),
508        ]);
509
510        let aabb = poly.aabb_2d();
511        assert_relative_eq!(aabb.min_x, 10.0);
512        assert_relative_eq!(aabb.min_y, 20.0);
513        assert_relative_eq!(aabb.max_x, 50.0);
514        assert_relative_eq!(aabb.max_y, 80.0);
515    }
516
517    #[test]
518    fn test_aabb_at_rotation_mirrored_delegates_when_not_mirrored() {
519        let poly = Geometry2D::new("P1").with_polygon(vec![
520            (10.0, 20.0),
521            (50.0, 20.0),
522            (50.0, 80.0),
523            (10.0, 80.0),
524        ]);
525        assert_eq!(
526            poly.aabb_at_rotation_mirrored(0.3, false),
527            poly.aabb_at_rotation(0.3)
528        );
529    }
530
531    #[test]
532    fn test_aabb_at_rotation_mirrored_reflects_asymmetric_extents() {
533        // Local extents asymmetric about the origin: x in [10, 50], not
534        // [-20, 20] — reflection (x -> -x) swaps and negates them to
535        // [-50, -10], a genuinely different AABB position (same width),
536        // which is exactly the case `clamp_placement_to_boundary_with_margin`
537        // needs the mirrored extents for.
538        let poly = Geometry2D::new("P1").with_polygon(vec![
539            (10.0, 20.0),
540            (50.0, 20.0),
541            (50.0, 80.0),
542            (10.0, 80.0),
543        ]);
544        let (min, max) = poly.aabb_at_rotation_mirrored(0.0, true);
545        assert_relative_eq!(min[0], -50.0);
546        assert_relative_eq!(max[0], -10.0);
547        // Y is unaffected by reflection across the y-axis.
548        assert_relative_eq!(min[1], 20.0);
549        assert_relative_eq!(max[1], 80.0);
550        // Width/height are preserved under reflection.
551        let (unmirrored_min, unmirrored_max) = poly.aabb_at_rotation(0.0);
552        assert_relative_eq!(max[0] - min[0], unmirrored_max[0] - unmirrored_min[0]);
553        assert_relative_eq!(max[1] - min[1], unmirrored_max[1] - unmirrored_min[1]);
554    }
555
556    #[test]
557    fn test_rectangle_is_convex() {
558        let rect = Geometry2D::rectangle("R1", 10.0, 10.0);
559        assert!(rect.is_convex());
560    }
561
562    #[test]
563    fn test_l_shape_is_not_convex() {
564        let l = Geometry2D::l_shape("L1", 20.0, 20.0, 10.0, 10.0);
565        assert!(!l.is_convex());
566    }
567
568    #[test]
569    fn test_convex_hull() {
570        let l = Geometry2D::l_shape("L1", 20.0, 20.0, 10.0, 10.0);
571        let hull = l.convex_hull();
572        // Convex hull of L-shape should be a quadrilateral
573        assert!(hull.len() >= 4);
574    }
575
576    #[test]
577    fn test_centroid() {
578        let rect = Geometry2D::rectangle("R1", 10.0, 10.0);
579        let centroid = rect.centroid();
580        assert_relative_eq!(centroid[0], 5.0, epsilon = 0.001);
581        assert_relative_eq!(centroid[1], 5.0, epsilon = 0.001);
582    }
583
584    #[test]
585    fn test_perimeter() {
586        let rect = Geometry2D::rectangle("R1", 10.0, 5.0);
587        assert_relative_eq!(rect.perimeter(), 30.0, epsilon = 0.001);
588    }
589
590    #[test]
591    fn test_validation() {
592        let valid = Geometry2D::rectangle("R1", 10.0, 10.0);
593        assert!(valid.validate().is_ok());
594
595        let invalid = Geometry2D::new("P1").with_polygon(vec![(0.0, 0.0), (1.0, 0.0)]);
596        assert!(invalid.validate().is_err());
597    }
598
599    #[test]
600    fn test_circle() {
601        let circle = Geometry2D::circle("C1", 10.0, 32);
602        let area = circle.measure();
603        let expected = std::f64::consts::PI * 10.0 * 10.0;
604        // Circle approximation should be close to actual area
605        assert_relative_eq!(area, expected, epsilon = 5.0);
606    }
607}