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 interior holes, each a ring.
169    pub fn holes(&self) -> &[Vec<(f64, f64)>] {
170        &self.holes
171    }
172
173    /// Returns the allowed rotation angles (for compatibility).
174    pub fn rotations(&self) -> Vec<f64> {
175        self.rotation_constraint.angles()
176    }
177
178    /// Returns whether flipping is allowed.
179    pub fn allow_flip(&self) -> bool {
180        self.allow_flip
181    }
182
183    /// Clears all cached values.
184    fn clear_cache(&mut self) {
185        self.cached_area = None;
186        self.cached_convex_hull = None;
187        self.cached_perimeter = None;
188        self.cached_is_convex = None;
189    }
190
191    /// Calculates the area of the polygon (exterior minus holes).
192    fn calculate_area(&self) -> f64 {
193        let hole_refs: Vec<&[(f64, f64)]> = self.holes.iter().map(|h| h.as_slice()).collect();
194        geom_polygon::area_with_holes(&self.exterior, &hole_refs)
195    }
196
197    /// Calculates the perimeter of the polygon.
198    fn calculate_perimeter(&self) -> f64 {
199        let mut perim = geom_polygon::perimeter(&self.exterior);
200        for hole in &self.holes {
201            perim += geom_polygon::perimeter(hole);
202        }
203        perim
204    }
205
206    /// Calculates the convex hull.
207    fn calculate_convex_hull(&self) -> Vec<(f64, f64)> {
208        geom_polygon::convex_hull(&self.exterior)
209    }
210
211    /// Checks if the polygon is convex.
212    fn calculate_is_convex(&self) -> bool {
213        if self.exterior.len() < 3 || !self.holes.is_empty() {
214            return false;
215        }
216
217        // A polygon is convex if all cross products of consecutive edge pairs
218        // have the same sign
219        let n = self.exterior.len();
220        let mut sign = 0i32;
221
222        for i in 0..n {
223            let (x1, y1) = self.exterior[i];
224            let (x2, y2) = self.exterior[(i + 1) % n];
225            let (x3, y3) = self.exterior[(i + 2) % n];
226
227            let cross = (x2 - x1) * (y3 - y2) - (y2 - y1) * (x3 - x2);
228
229            if cross.abs() > 1e-10 {
230                let current_sign = if cross > 0.0 { 1 } else { -1 };
231                if sign == 0 {
232                    sign = current_sign;
233                } else if sign != current_sign {
234                    return false;
235                }
236            }
237        }
238
239        true
240    }
241}
242
243#[cfg(feature = "serde")]
244impl Geometry2D {
245    /// The geometry `request` describes, `index` being its position in the
246    /// request's `geometries` -- the position a refusal names.
247    ///
248    /// # Errors
249    ///
250    /// `parameter_out_of_range` (`geometries[index].quantity`, with the id)
251    /// for a quantity below 1, a negative one included.
252    pub fn from_request(
253        index: usize,
254        request: u_nesting_core::api_types::Geometry2DRequest,
255    ) -> Result<Self> {
256        let points = |ring: Vec<[f64; 2]>| ring.into_iter().map(|[x, y]| (x, y)).collect();
257        let quantity = u_nesting_core::check_quantity(
258            &format!("geometries[{index}].quantity"),
259            &request.id,
260            request.quantity,
261        )?;
262        let mut geom = Self::new(request.id)
263            .with_polygon(points(request.polygon))
264            .with_quantity(quantity)
265            .with_flip(request.allow_flip);
266        if let Some(rotations) = request.rotations {
267            geom = geom.with_rotations_deg(rotations);
268        }
269        for hole in request.holes.into_iter().flatten() {
270            geom = geom.with_hole(points(hole));
271        }
272        Ok(geom)
273    }
274
275    /// The geometries a request's `geometries` describe, in order; see
276    /// [`Geometry2D::from_request`].
277    ///
278    /// # Errors
279    ///
280    /// The first geometry's refusal.
281    pub fn from_requests(
282        requests: Vec<u_nesting_core::api_types::Geometry2DRequest>,
283    ) -> Result<Vec<Self>> {
284        requests
285            .into_iter()
286            .enumerate()
287            .map(|(index, request)| Self::from_request(index, request))
288            .collect()
289    }
290}
291
292impl Geometry for Geometry2D {
293    type Scalar = f64;
294
295    fn id(&self) -> &GeometryId {
296        &self.id
297    }
298
299    fn quantity(&self) -> usize {
300        self.quantity
301    }
302
303    fn measure(&self) -> f64 {
304        if let Some(area) = self.cached_area {
305            area
306        } else {
307            self.calculate_area()
308        }
309    }
310
311    fn aabb(&self) -> ([f64; 2], [f64; 2]) {
312        let (min, max) = self.aabb_vec();
313        ([min[0], min[1]], [max[0], max[1]])
314    }
315
316    fn aabb_vec(&self) -> (Vec<f64>, Vec<f64>) {
317        if self.exterior.is_empty() {
318            return (vec![0.0, 0.0], vec![0.0, 0.0]);
319        }
320
321        let mut min_x = f64::MAX;
322        let mut min_y = f64::MAX;
323        let mut max_x = f64::MIN;
324        let mut max_y = f64::MIN;
325
326        for &(x, y) in &self.exterior {
327            min_x = min_x.min(x);
328            min_y = min_y.min(y);
329            max_x = max_x.max(x);
330            max_y = max_y.max(y);
331        }
332
333        (vec![min_x, min_y], vec![max_x, max_y])
334    }
335
336    fn centroid(&self) -> Vec<f64> {
337        let hole_refs: Vec<&[(f64, f64)]> = self.holes.iter().map(|h| h.as_slice()).collect();
338        if let Some((cx, cy)) = geom_polygon::centroid_with_holes(&self.exterior, &hole_refs) {
339            vec![cx, cy]
340        } else {
341            vec![0.0, 0.0]
342        }
343    }
344
345    fn validate(&self) -> Result<()> {
346        if self.exterior.len() < 3 {
347            return Err(Error::invalid_geometry(
348                Some(&self.id),
349                format!("Polygon '{}' must have at least 3 vertices", self.id),
350            ));
351        }
352
353        u_nesting_core::check_quantity("quantity", &self.id, self.quantity as i128)?;
354
355        // An empty angle list allows no orientation at all; reading it as 0°
356        // would place the part in an orientation the caller did not list.
357        if let RotationConstraint::Discrete(angles) = &self.rotation_constraint {
358            if angles.is_empty() {
359                return Err(Error::invalid_geometry(
360                    Some(&self.id),
361                    format!(
362                        "Rotations for '{}' are empty; list at least one angle, \
363                     or leave rotations out for a fixed orientation",
364                        self.id
365                    ),
366                ));
367            }
368            if let Some(a) = angles.iter().find(|a| !a.is_finite()) {
369                return Err(Error::invalid_geometry(
370                    Some(&self.id),
371                    format!(
372                        "Rotations for '{}' contain a non-finite angle ({a})",
373                        self.id
374                    ),
375                ));
376            }
377        }
378
379        // Reject degenerate (zero-area / collinear) polygons: they make NFP and
380        // collision tests meaningless. Threshold scales with extent so a small
381        // but legitimate piece survives while a truly collinear ring is caught.
382        let (min, max) = self.aabb_vec();
383        let scale = (max[0] - min[0]).max(max[1] - min[1]).max(1.0);
384        let area_eps = 1e-9 * scale * scale;
385        if geom_polygon::signed_area(&self.exterior).abs() < area_eps {
386            return Err(Error::invalid_geometry(
387                Some(&self.id),
388                format!(
389                    "Polygon '{}' is degenerate (zero area / collinear vertices)",
390                    self.id
391                ),
392            ));
393        }
394
395        // Reject self-intersecting exteriors (e.g. a bow-tie): a non-simple
396        // outline breaks point-in-polygon and NFP, which can produce overlapping
397        // placements downstream.
398        if !crate::polygon_ops::is_simple_polygon(&self.exterior) {
399            return Err(Error::invalid_geometry(
400                Some(&self.id),
401                format!("Polygon '{}' is self-intersecting (edges cross)", self.id),
402            ));
403        }
404
405        Ok(())
406    }
407
408    fn rotation_constraint(&self) -> &RotationConstraint<f64> {
409        &self.rotation_constraint
410    }
411
412    fn allow_mirror(&self) -> bool {
413        self.allow_flip
414    }
415
416    fn priority(&self) -> i32 {
417        self.priority
418    }
419}
420
421impl Geometry2DExt for Geometry2D {
422    fn aabb_2d(&self) -> AABB2D<f64> {
423        let (min, max) = self.aabb_vec();
424        AABB2D::new(min[0], min[1], max[0], max[1])
425    }
426
427    fn outer_ring(&self) -> &[(f64, f64)] {
428        &self.exterior
429    }
430
431    fn holes(&self) -> &[Vec<(f64, f64)>] {
432        &self.holes
433    }
434
435    fn is_convex(&self) -> bool {
436        if let Some(is_convex) = self.cached_is_convex {
437            is_convex
438        } else {
439            self.calculate_is_convex()
440        }
441    }
442
443    fn convex_hull(&self) -> Vec<(f64, f64)> {
444        if let Some(ref hull) = self.cached_convex_hull {
445            hull.clone()
446        } else {
447            self.calculate_convex_hull()
448        }
449    }
450
451    fn perimeter(&self) -> f64 {
452        if let Some(perim) = self.cached_perimeter {
453            perim
454        } else {
455            self.calculate_perimeter()
456        }
457    }
458}
459
460impl Geometry2D {
461    /// Computes the AABB of the geometry at a given rotation angle (in radians).
462    ///
463    /// Returns (min, max) as ([min_x, min_y], [max_x, max_y])
464    pub fn aabb_at_rotation(&self, rotation: f64) -> ([f64; 2], [f64; 2]) {
465        if rotation.abs() < 1e-10 {
466            return self.aabb();
467        }
468
469        let cos_r = rotation.cos();
470        let sin_r = rotation.sin();
471
472        let mut min_x = f64::INFINITY;
473        let mut min_y = f64::INFINITY;
474        let mut max_x = f64::NEG_INFINITY;
475        let mut max_y = f64::NEG_INFINITY;
476
477        for &(x, y) in &self.exterior {
478            let rx = x * cos_r - y * sin_r;
479            let ry = x * sin_r + y * cos_r;
480            min_x = min_x.min(rx);
481            min_y = min_y.min(ry);
482            max_x = max_x.max(rx);
483            max_y = max_y.max(ry);
484        }
485
486        ([min_x, min_y], [max_x, max_y])
487    }
488
489    /// Same as [`Self::aabb_at_rotation`], but the geometry can be mirrored
490    /// first (`allow_flip` support) — mirror-then-rotate, matching the order
491    /// every other mirror-aware transform in this crate uses
492    /// (`crate::polygon_ops::mirror_polygon`,
493    /// [`crate::nfp::compute_nfp_mirrored`],
494    /// [`crate::nfp::PlacedGeometry::translated_exterior`]).
495    ///
496    /// A reflection preserves AABB *width*/*height* (mirroring negates local
497    /// x, which swaps min/max but not their difference), but **not** the
498    /// extents' offsets from the local origin when the geometry isn't
499    /// symmetric about it — `aabb_at_rotation(rotation)` alone silently
500    /// reuses the unmirrored offsets for a mirrored placement, which is
501    /// exactly wrong for the boundary-clamp step every mirror-supporting
502    /// strategy runs after picking a candidate.
503    pub fn aabb_at_rotation_mirrored(&self, rotation: f64, mirror: bool) -> ([f64; 2], [f64; 2]) {
504        if !mirror {
505            return self.aabb_at_rotation(rotation);
506        }
507
508        let cos_r = rotation.cos();
509        let sin_r = rotation.sin();
510
511        let mut min_x = f64::INFINITY;
512        let mut min_y = f64::INFINITY;
513        let mut max_x = f64::NEG_INFINITY;
514        let mut max_y = f64::NEG_INFINITY;
515
516        for &(x, y) in &self.exterior {
517            let mx = -x; // mirror across the y-axis first
518            let rx = mx * cos_r - y * sin_r;
519            let ry = mx * sin_r + y * cos_r;
520            min_x = min_x.min(rx);
521            min_y = min_y.min(ry);
522            max_x = max_x.max(rx);
523            max_y = max_y.max(ry);
524        }
525
526        ([min_x, min_y], [max_x, max_y])
527    }
528
529    /// Returns the width and height of the AABB at a given rotation.
530    pub fn dimensions_at_rotation(&self, rotation: f64) -> (f64, f64) {
531        let (min, max) = self.aabb_at_rotation(rotation);
532        (max[0] - min[0], max[1] - min[1])
533    }
534
535    /// Returns the exterior ring rotated by `rotation` (radians, CCW about the
536    /// origin) and translated to placement position `(x, y)`.
537    ///
538    /// This is the piece's actual footprint on the sheet — the same transform
539    /// used by [`Self::aabb_at_rotation`], made concrete per vertex so callers
540    /// can perform exact polygon-in-polygon containment against a non-rectangular
541    /// boundary (AABB containment is only exact for axis-aligned rectangles).
542    pub fn transformed_exterior(&self, x: f64, y: f64, rotation: f64) -> Vec<(f64, f64)> {
543        let cos_r = rotation.cos();
544        let sin_r = rotation.sin();
545        self.exterior
546            .iter()
547            .map(|&(vx, vy)| {
548                let rx = vx * cos_r - vy * sin_r;
549                let ry = vx * sin_r + vy * cos_r;
550                (x + rx, y + ry)
551            })
552            .collect()
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use approx::assert_relative_eq;
560
561    #[test]
562    fn test_rectangle_area() {
563        let rect = Geometry2D::rectangle("R1", 10.0, 5.0);
564        assert_relative_eq!(rect.measure(), 50.0, epsilon = 0.001);
565    }
566
567    #[test]
568    fn test_polygon_with_hole() {
569        let poly = Geometry2D::new("P1")
570            .with_polygon(vec![(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)])
571            .with_hole(vec![(25.0, 25.0), (75.0, 25.0), (75.0, 75.0), (25.0, 75.0)]);
572
573        // Area = 100*100 - 50*50 = 10000 - 2500 = 7500
574        assert_relative_eq!(poly.measure(), 7500.0, epsilon = 0.001);
575    }
576
577    #[test]
578    fn test_aabb() {
579        let poly = Geometry2D::new("P1").with_polygon(vec![
580            (10.0, 20.0),
581            (50.0, 20.0),
582            (50.0, 80.0),
583            (10.0, 80.0),
584        ]);
585
586        let aabb = poly.aabb_2d();
587        assert_relative_eq!(aabb.min_x, 10.0);
588        assert_relative_eq!(aabb.min_y, 20.0);
589        assert_relative_eq!(aabb.max_x, 50.0);
590        assert_relative_eq!(aabb.max_y, 80.0);
591    }
592
593    #[test]
594    fn test_aabb_at_rotation_mirrored_delegates_when_not_mirrored() {
595        let poly = Geometry2D::new("P1").with_polygon(vec![
596            (10.0, 20.0),
597            (50.0, 20.0),
598            (50.0, 80.0),
599            (10.0, 80.0),
600        ]);
601        assert_eq!(
602            poly.aabb_at_rotation_mirrored(0.3, false),
603            poly.aabb_at_rotation(0.3)
604        );
605    }
606
607    #[test]
608    fn test_aabb_at_rotation_mirrored_reflects_asymmetric_extents() {
609        // Local extents asymmetric about the origin: x in [10, 50], not
610        // [-20, 20] — reflection (x -> -x) swaps and negates them to
611        // [-50, -10], a genuinely different AABB position (same width),
612        // which is exactly the case `clamp_placement_to_boundary_with_margin`
613        // needs the mirrored extents for.
614        let poly = Geometry2D::new("P1").with_polygon(vec![
615            (10.0, 20.0),
616            (50.0, 20.0),
617            (50.0, 80.0),
618            (10.0, 80.0),
619        ]);
620        let (min, max) = poly.aabb_at_rotation_mirrored(0.0, true);
621        assert_relative_eq!(min[0], -50.0);
622        assert_relative_eq!(max[0], -10.0);
623        // Y is unaffected by reflection across the y-axis.
624        assert_relative_eq!(min[1], 20.0);
625        assert_relative_eq!(max[1], 80.0);
626        // Width/height are preserved under reflection.
627        let (unmirrored_min, unmirrored_max) = poly.aabb_at_rotation(0.0);
628        assert_relative_eq!(max[0] - min[0], unmirrored_max[0] - unmirrored_min[0]);
629        assert_relative_eq!(max[1] - min[1], unmirrored_max[1] - unmirrored_min[1]);
630    }
631
632    #[test]
633    fn test_rectangle_is_convex() {
634        let rect = Geometry2D::rectangle("R1", 10.0, 10.0);
635        assert!(rect.is_convex());
636    }
637
638    #[test]
639    fn test_l_shape_is_not_convex() {
640        let l = Geometry2D::l_shape("L1", 20.0, 20.0, 10.0, 10.0);
641        assert!(!l.is_convex());
642    }
643
644    #[test]
645    fn test_convex_hull() {
646        let l = Geometry2D::l_shape("L1", 20.0, 20.0, 10.0, 10.0);
647        let hull = l.convex_hull();
648        // Convex hull of L-shape should be a quadrilateral
649        assert!(hull.len() >= 4);
650    }
651
652    #[test]
653    fn test_centroid() {
654        let rect = Geometry2D::rectangle("R1", 10.0, 10.0);
655        let centroid = rect.centroid();
656        assert_relative_eq!(centroid[0], 5.0, epsilon = 0.001);
657        assert_relative_eq!(centroid[1], 5.0, epsilon = 0.001);
658    }
659
660    #[test]
661    fn test_perimeter() {
662        let rect = Geometry2D::rectangle("R1", 10.0, 5.0);
663        assert_relative_eq!(rect.perimeter(), 30.0, epsilon = 0.001);
664    }
665
666    #[test]
667    fn test_validation() {
668        let valid = Geometry2D::rectangle("R1", 10.0, 10.0);
669        assert!(valid.validate().is_ok());
670
671        let invalid = Geometry2D::new("P1").with_polygon(vec![(0.0, 0.0), (1.0, 0.0)]);
672        assert!(invalid.validate().is_err());
673    }
674
675    #[test]
676    fn test_circle() {
677        let circle = Geometry2D::circle("C1", 10.0, 32);
678        let area = circle.measure();
679        let expected = std::f64::consts::PI * 10.0 * 10.0;
680        // Circle approximation should be close to actual area
681        assert_relative_eq!(area, expected, epsilon = 5.0);
682    }
683}