Skip to main content

u_nesting_d2/
nfp.rs

1//! No-Fit Polygon (NFP) computation.
2//!
3//! The NFP of two polygons A and B represents all positions where the reference
4//! point of B can be placed such that B touches or overlaps A.
5//!
6//! This module implements:
7//! - **Convex case**: Minkowski sum algorithm (O(n+m) for convex polygons)
8//! - **Non-convex case**: Convex decomposition + union approach using `i_overlay`
9//! - **Sliding algorithm**: Burke et al. (2007) orbiting/sliding approach for robust NFP
10//!
11//! ## Algorithm Selection
12//!
13//! Use [`NfpMethod`] to choose the algorithm:
14//! - `MinkowskiSum`: Fast for convex polygons, uses decomposition for non-convex
15//! - `Sliding`: More robust for complex shapes, follows polygon boundary
16//!
17//! ```rust,ignore
18//! use u_nesting_d2::nfp::{compute_nfp_with_method, NfpMethod};
19//!
20//! let nfp = compute_nfp_with_method(&stationary, &orbiting, 0.0, NfpMethod::Sliding)?;
21//! ```
22
23use crate::geometry::Geometry2D;
24use crate::nfp_sliding::{compute_nfp_sliding, SlidingNfpConfig};
25use i_overlay::core::fill_rule::FillRule;
26use i_overlay::core::overlay_rule::OverlayRule;
27use i_overlay::float::single::SingleFloatOverlay;
28#[cfg(feature = "parallel")]
29use rayon::prelude::*;
30use std::collections::HashMap;
31use std::f64::consts::PI;
32use std::sync::{Arc, RwLock};
33use u_nesting_core::geom::polygon as geom_polygon;
34use u_nesting_core::geometry::Geometry2DExt;
35use u_nesting_core::robust::{orient2d_filtered, Orientation};
36use u_nesting_core::{Error, Result};
37
38/// Rotates an NFP around the origin by the given angle (in radians).
39///
40/// This is used when computing NFP with relative rotation and then
41/// transforming it to the actual placed geometry's rotation.
42pub fn rotate_nfp(nfp: &Nfp, angle: f64) -> Nfp {
43    if angle.abs() < 1e-10 {
44        return nfp.clone();
45    }
46
47    let cos_a = angle.cos();
48    let sin_a = angle.sin();
49
50    Nfp {
51        polygons: nfp
52            .polygons
53            .iter()
54            .map(|polygon| {
55                polygon
56                    .iter()
57                    .map(|&(x, y)| (x * cos_a - y * sin_a, x * sin_a + y * cos_a))
58                    .collect()
59            })
60            .collect(),
61    }
62}
63
64/// Translates an NFP by the given offset.
65pub fn translate_nfp(nfp: &Nfp, offset: (f64, f64)) -> Nfp {
66    Nfp {
67        polygons: nfp
68            .polygons
69            .iter()
70            .map(|polygon| {
71                polygon
72                    .iter()
73                    .map(|(x, y)| (x + offset.0, y + offset.1))
74                    .collect()
75            })
76            .collect(),
77    }
78}
79
80/// NFP computation result.
81#[derive(Debug, Clone)]
82pub struct Nfp {
83    /// The computed NFP polygon(s).
84    /// Multiple polygons can result from non-convex shapes.
85    pub polygons: Vec<Vec<(f64, f64)>>,
86}
87
88impl Nfp {
89    /// Creates a new empty NFP.
90    pub fn new() -> Self {
91        Self {
92            polygons: Vec::new(),
93        }
94    }
95
96    /// Creates an NFP with a single polygon.
97    pub fn from_polygon(polygon: Vec<(f64, f64)>) -> Self {
98        Self {
99            polygons: vec![polygon],
100        }
101    }
102
103    /// Creates an NFP with multiple polygons.
104    pub fn from_polygons(polygons: Vec<Vec<(f64, f64)>>) -> Self {
105        Self { polygons }
106    }
107
108    /// Returns true if the NFP is empty.
109    pub fn is_empty(&self) -> bool {
110        self.polygons.is_empty()
111    }
112
113    /// Returns the total vertex count across all polygons.
114    pub fn vertex_count(&self) -> usize {
115        self.polygons.iter().map(|p| p.len()).sum()
116    }
117}
118
119impl Default for Nfp {
120    fn default() -> Self {
121        Self::new()
122    }
123}
124
125// ============================================================================
126// NFP Method Selection
127// ============================================================================
128
129/// Method for computing No-Fit Polygons.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
131pub enum NfpMethod {
132    /// Minkowski sum algorithm.
133    ///
134    /// - **Convex polygons**: O(n+m) time complexity
135    /// - **Non-convex polygons**: Uses convex decomposition + union
136    /// - Best for: Simple shapes, fast computation
137    #[default]
138    MinkowskiSum,
139
140    /// Sliding/orbiting algorithm (Burke et al. 2007).
141    ///
142    /// - Traces the NFP boundary by sliding one polygon around another
143    /// - More robust for complex interlocking shapes
144    /// - Better handles edge cases like perfect fits
145    /// - Best for: Complex non-convex shapes, high accuracy requirements
146    Sliding,
147}
148
149/// Configuration for NFP computation.
150#[derive(Debug, Clone)]
151pub struct NfpConfig {
152    /// The method to use for NFP computation.
153    pub method: NfpMethod,
154    /// Tolerance for contact detection (Sliding method).
155    pub contact_tolerance: f64,
156    /// Maximum iterations for sliding algorithm.
157    pub max_iterations: usize,
158}
159
160impl Default for NfpConfig {
161    fn default() -> Self {
162        Self {
163            method: NfpMethod::MinkowskiSum,
164            contact_tolerance: 1e-6,
165            max_iterations: 10000,
166        }
167    }
168}
169
170impl NfpConfig {
171    /// Creates a new config with the specified method.
172    pub fn with_method(method: NfpMethod) -> Self {
173        Self {
174            method,
175            ..Default::default()
176        }
177    }
178
179    /// Sets the contact tolerance (for Sliding method).
180    pub fn with_tolerance(mut self, tolerance: f64) -> Self {
181        self.contact_tolerance = tolerance;
182        self
183    }
184
185    /// Sets the maximum iterations (for Sliding method).
186    pub fn with_max_iterations(mut self, max_iter: usize) -> Self {
187        self.max_iterations = max_iter;
188        self
189    }
190}
191
192/// Computes the No-Fit Polygon between two geometries using the specified method.
193///
194/// # Arguments
195/// * `stationary` - The fixed polygon
196/// * `orbiting` - The polygon to be placed
197/// * `rotation` - Rotation angle of the orbiting polygon in radians
198/// * `method` - The algorithm to use
199///
200/// # Returns
201/// The computed NFP, or an error if computation fails.
202pub fn compute_nfp_with_method(
203    stationary: &Geometry2D,
204    orbiting: &Geometry2D,
205    rotation: f64,
206    method: NfpMethod,
207) -> Result<Nfp> {
208    compute_nfp_with_config(
209        stationary,
210        orbiting,
211        rotation,
212        &NfpConfig::with_method(method),
213    )
214}
215
216/// Computes the No-Fit Polygon between two geometries with full configuration.
217///
218/// # Arguments
219/// * `stationary` - The fixed polygon
220/// * `orbiting` - The polygon to be placed
221/// * `rotation` - Rotation angle of the orbiting polygon in radians
222/// * `config` - Configuration including method and parameters
223///
224/// # Returns
225/// The computed NFP, or an error if computation fails.
226pub fn compute_nfp_with_config(
227    stationary: &Geometry2D,
228    orbiting: &Geometry2D,
229    rotation: f64,
230    config: &NfpConfig,
231) -> Result<Nfp> {
232    let stat_exterior = stationary.exterior();
233    let orb_exterior = orbiting.exterior();
234
235    if stat_exterior.len() < 3 || orb_exterior.len() < 3 {
236        return Err(Error::invalid_geometry(
237            None,
238            "Polygons must have at least 3 vertices".into(),
239        ));
240    }
241
242    // Apply rotation to orbiting polygon
243    let rotated_orbiting = rotate_polygon(orb_exterior, rotation);
244
245    match config.method {
246        NfpMethod::MinkowskiSum => {
247            // Use existing Minkowski sum implementation
248            if stationary.is_convex()
249                && is_polygon_convex(&rotated_orbiting)
250                && stationary.holes().is_empty()
251            {
252                compute_nfp_convex(stat_exterior, &rotated_orbiting)
253            } else {
254                compute_nfp_general(stat_exterior, &rotated_orbiting)
255            }
256        }
257        NfpMethod::Sliding => {
258            // Use sliding algorithm
259            let sliding_config = SlidingNfpConfig {
260                contact_tolerance: config.contact_tolerance,
261                max_iterations: config.max_iterations,
262                min_translation: config.contact_tolerance * 0.01,
263            };
264
265            // Reflect the orbiting polygon (NFP requires -B)
266            let reflected: Vec<(f64, f64)> =
267                rotated_orbiting.iter().map(|&(x, y)| (-x, -y)).collect();
268
269            compute_nfp_sliding(stat_exterior, &reflected, &sliding_config)
270        }
271    }
272}
273
274/// Computes the No-Fit Polygon between two geometries.
275///
276/// The NFP represents all positions where the orbiting polygon would
277/// overlap with the stationary polygon.
278///
279/// # Algorithm Selection
280/// - If both polygons are convex: uses fast Minkowski sum (O(n+m))
281/// - Otherwise: uses convex decomposition + union approach
282///
283/// # Arguments
284/// * `stationary` - The fixed polygon
285/// * `orbiting` - The polygon to be placed
286/// * `rotation` - Rotation angle of the orbiting polygon in radians
287///
288/// # Returns
289/// The computed NFP, or an error if computation fails.
290pub fn compute_nfp(stationary: &Geometry2D, orbiting: &Geometry2D, rotation: f64) -> Result<Nfp> {
291    compute_nfp_mirrored(stationary, orbiting, rotation, false, false)
292}
293
294/// Computes the No-Fit Polygon, optionally mirroring either input polygon
295/// first (`allow_flip` support).
296///
297/// Both sides can independently be mirrored: `stationary` here is typically
298/// an already-placed piece (which may itself have been placed mirrored), and
299/// `orbiting` is the new candidate being evaluated. Mirroring is applied
300/// before rotation on each side — reflect once, then enumerate rotation
301/// candidates over the reflected outline, matching how rotation candidates
302/// are already enumerated (Bennell & Oliveira 2008: reflection is "another
303/// orientation candidate", not a change to the NFP algorithm itself).
304///
305/// # Arguments
306/// * `mirror_stationary` - When true, reflects `stationary` across the
307///   y-axis before use (see `crate::polygon_ops::mirror_polygon`).
308/// * `mirror_orbiting` - Same, for `orbiting`, applied before rotation.
309pub fn compute_nfp_mirrored(
310    stationary: &Geometry2D,
311    orbiting: &Geometry2D,
312    rotation: f64,
313    mirror_stationary: bool,
314    mirror_orbiting: bool,
315) -> Result<Nfp> {
316    // Get the polygons
317    let stat_exterior = stationary.exterior();
318    let orb_exterior = orbiting.exterior();
319
320    if stat_exterior.len() < 3 || orb_exterior.len() < 3 {
321        return Err(Error::invalid_geometry(
322            None,
323            "Polygons must have at least 3 vertices".into(),
324        ));
325    }
326
327    // Mirror (if requested) — reflection doesn't change convexity or hole
328    // emptiness, so `stationary.is_convex()`/`.holes()` below stay valid
329    // metadata queries against the *original* geometry either way.
330    let base_stationary = if mirror_stationary {
331        crate::polygon_ops::mirror_polygon(stat_exterior)
332    } else {
333        stat_exterior.to_vec()
334    };
335    let base_orbiting = if mirror_orbiting {
336        crate::polygon_ops::mirror_polygon(orb_exterior)
337    } else {
338        orb_exterior.to_vec()
339    };
340    let rotated_orbiting = rotate_polygon(&base_orbiting, rotation);
341
342    // Check if both are convex for fast path
343    if stationary.is_convex()
344        && is_polygon_convex(&rotated_orbiting)
345        && stationary.holes().is_empty()
346    {
347        // Fast path: Minkowski sum for convex polygons
348        compute_nfp_convex(&base_stationary, &rotated_orbiting)
349    } else {
350        // General case: decomposition + union
351        compute_nfp_general(&base_stationary, &rotated_orbiting)
352    }
353}
354
355/// Computes the Inner-Fit Polygon (IFP) of a geometry within a boundary.
356///
357/// The IFP represents all valid positions where the reference point of
358/// a geometry can be placed within the boundary.
359///
360/// # Arguments
361/// * `boundary_polygon` - The boundary polygon vertices (counter-clockwise)
362/// * `geometry` - The geometry to fit inside
363/// * `rotation` - Rotation angle of the geometry in radians
364///
365/// # Returns
366/// The computed IFP, or an error if computation fails.
367pub fn compute_ifp(
368    boundary_polygon: &[(f64, f64)],
369    geometry: &Geometry2D,
370    rotation: f64,
371) -> Result<Nfp> {
372    compute_ifp_with_margin(boundary_polygon, geometry, rotation, 0.0)
373}
374
375/// Computes the Inner-Fit Polygon (IFP) of a geometry within a boundary with margin.
376///
377/// The IFP represents all valid positions where the reference point of
378/// a geometry can be placed within the boundary, accounting for a margin
379/// (offset) from the boundary edges.
380///
381/// # Arguments
382/// * `boundary_polygon` - The boundary polygon vertices (counter-clockwise)
383/// * `geometry` - The geometry to fit inside
384/// * `rotation` - Rotation angle of the geometry in radians
385/// * `margin` - Distance to maintain from boundary edges (applied to both boundary and geometry)
386///
387/// # Returns
388/// The computed IFP, or an error if computation fails.
389pub fn compute_ifp_with_margin(
390    boundary_polygon: &[(f64, f64)],
391    geometry: &Geometry2D,
392    rotation: f64,
393    margin: f64,
394) -> Result<Nfp> {
395    compute_ifp_with_margin_and_mirror(boundary_polygon, geometry, rotation, margin, false)
396}
397
398/// Computes the Inner-Fit Polygon with margin, optionally mirroring the
399/// geometry first (`allow_flip` support) — same mirror-then-rotate ordering
400/// as [`compute_nfp_mirrored`].
401///
402/// # Arguments
403/// * `mirror` - When true, reflects `geometry` across the y-axis before
404///   rotating (see `crate::polygon_ops::mirror_polygon`).
405pub fn compute_ifp_with_margin_and_mirror(
406    boundary_polygon: &[(f64, f64)],
407    geometry: &Geometry2D,
408    rotation: f64,
409    margin: f64,
410    mirror: bool,
411) -> Result<Nfp> {
412    if boundary_polygon.len() < 3 {
413        return Err(Error::invalid_boundary(
414            None,
415            "Boundary must have at least 3 vertices".into(),
416        ));
417    }
418
419    let geom_exterior = geometry.exterior();
420    if geom_exterior.len() < 3 {
421        return Err(Error::invalid_geometry(
422            None,
423            "Geometry must have at least 3 vertices".into(),
424        ));
425    }
426
427    // Mirror (if requested), then rotate — order matters, see doc comment.
428    let base_geom = if mirror {
429        crate::polygon_ops::mirror_polygon(geom_exterior)
430    } else {
431        geom_exterior.to_vec()
432    };
433    let rotated_geom = rotate_polygon(&base_geom, rotation);
434
435    // Apply margin by shrinking the boundary inward
436    let effective_boundary = if margin > 0.0 {
437        shrink_polygon(boundary_polygon, margin)?
438    } else {
439        boundary_polygon.to_vec()
440    };
441
442    if effective_boundary.len() < 3 {
443        return Err(Error::invalid_boundary(
444            None,
445            "Boundary too small after applying margin".into(),
446        ));
447    }
448
449    // The IFP (Inner-Fit Polygon) is computed using Minkowski EROSION:
450    // IFP = boundary ⊖ geometry = ∩_{g ∈ geometry} (boundary - g)
451    //
452    // This is the set of all positions p where placing the geometry at p
453    // keeps ALL vertices inside the boundary.
454    //
455    // NOTE: This is different from Minkowski SUM (⊕) which gives UNION not intersection!
456    // Previous implementation incorrectly used Minkowski sum.
457    compute_minkowski_erosion(&effective_boundary, &rotated_geom)
458}
459
460/// Computes Minkowski erosion of boundary by geometry: B ⊖ G = ∩_{g ∈ G} (B - g)
461///
462/// This gives all positions where placing geometry keeps it entirely inside boundary.
463/// For a rectangular boundary and convex geometry, this shrinks the boundary
464/// by the geometry's extent in each direction.
465fn compute_minkowski_erosion(boundary: &[(f64, f64)], geometry: &[(f64, f64)]) -> Result<Nfp> {
466    if boundary.len() < 3 || geometry.len() < 3 {
467        return Err(Error::invalid_geometry(
468            None,
469            "Both boundary and geometry must have at least 3 vertices".into(),
470        ));
471    }
472
473    // Fast path for rectangular boundary (common case)
474    let (b_min_x, b_min_y, b_max_x, b_max_y) = bounding_box(boundary);
475    let is_rect = boundary.len() == 4
476        && boundary.iter().all(|&(x, y)| {
477            ((x - b_min_x).abs() < 1e-10 || (x - b_max_x).abs() < 1e-10)
478                && ((y - b_min_y).abs() < 1e-10 || (y - b_max_y).abs() < 1e-10)
479        });
480
481    // Get geometry bounding box (AABB of the geometry in its current orientation)
482    let (g_min_x, g_min_y, g_max_x, g_max_y) = bounding_box(geometry);
483
484    if is_rect {
485        // For rectangular boundary: shrink by geometry extents
486        // If geometry reference is at origin and vertices span [g_min, g_max],
487        // then placement p is valid iff:
488        //   p + g_min_x >= b_min_x  =>  p_x >= b_min_x - g_min_x
489        //   p + g_max_x <= b_max_x  =>  p_x <= b_max_x - g_max_x
490        //   p + g_min_y >= b_min_y  =>  p_y >= b_min_y - g_min_y
491        //   p + g_max_y <= b_max_y  =>  p_y <= b_max_y - g_max_y
492        let ifp_min_x = b_min_x - g_min_x;
493        let ifp_max_x = b_max_x - g_max_x;
494        let ifp_min_y = b_min_y - g_min_y;
495        let ifp_max_y = b_max_y - g_max_y;
496
497        // Check if IFP is valid (non-empty)
498        if ifp_min_x > ifp_max_x + 1e-10 || ifp_min_y > ifp_max_y + 1e-10 {
499            return Err(Error::invalid_geometry(
500                None,
501                "Geometry too large to fit in boundary".into(),
502            ));
503        }
504
505        // Clamp to ensure valid rectangle
506        let ifp_min_x = ifp_min_x.min(ifp_max_x);
507        let ifp_min_y = ifp_min_y.min(ifp_max_y);
508
509        return Ok(Nfp::from_polygon(vec![
510            (ifp_min_x, ifp_min_y),
511            (ifp_max_x, ifp_min_y),
512            (ifp_max_x, ifp_max_y),
513            (ifp_min_x, ifp_max_y),
514        ]));
515    }
516
517    // General case: intersect translated boundaries
518    // IFP = ∩_{g ∈ G} (B - g)
519    // For each geometry vertex g, translate boundary by -g, then intersect all
520    compute_minkowski_erosion_general(boundary, geometry)
521}
522
523/// General Minkowski erosion using polygon intersection via i_overlay
524fn compute_minkowski_erosion_general(
525    boundary: &[(f64, f64)],
526    geometry: &[(f64, f64)],
527) -> Result<Nfp> {
528    if geometry.is_empty() {
529        return Ok(Nfp::from_polygon(boundary.to_vec()));
530    }
531
532    // Start with boundary translated by first geometry vertex
533    let first_g = geometry[0];
534    let mut result: Vec<[f64; 2]> = boundary
535        .iter()
536        .map(|&(x, y)| [x - first_g.0, y - first_g.1])
537        .collect();
538
539    // Intersect with boundary translated by each remaining vertex
540    for &(gx, gy) in geometry.iter().skip(1) {
541        let translated: Vec<[f64; 2]> = boundary.iter().map(|&(x, y)| [x - gx, y - gy]).collect();
542
543        // Intersect current result with translated boundary using i_overlay
544        let shapes = result.overlay(&[translated], OverlayRule::Intersect, FillRule::NonZero);
545
546        if shapes.is_empty() {
547            return Err(Error::invalid_geometry(
548                None,
549                "Geometry too large to fit in boundary".into(),
550            ));
551        }
552
553        // Take the first (largest) resulting polygon
554        result = Vec::new();
555        for shape in &shapes {
556            for contour in shape {
557                if contour.len() >= 3 {
558                    result = contour.clone();
559                    break;
560                }
561            }
562            if !result.is_empty() {
563                break;
564            }
565        }
566
567        if result.len() < 3 {
568            return Err(Error::invalid_geometry(
569                None,
570                "Geometry too large to fit in boundary".into(),
571            ));
572        }
573    }
574
575    // Convert back to (f64, f64) format
576    let result_tuples: Vec<(f64, f64)> = result.iter().map(|&[x, y]| (x, y)).collect();
577    Ok(Nfp::from_polygon(result_tuples))
578}
579
580/// Shrinks a polygon by moving all edges inward by the given offset.
581///
582/// For axis-aligned rectangles (the common case for boundaries), this shrinks
583/// each edge inward exactly. Any other polygon is offset inward by at least
584/// `offset` everywhere (see `polygon_ops::offset_polygon`).
585fn shrink_polygon(polygon: &[(f64, f64)], offset: f64) -> Result<Vec<(f64, f64)>> {
586    if polygon.len() < 3 {
587        return Err(Error::invalid_geometry(
588            None,
589            "Polygon must have at least 3 vertices".into(),
590        ));
591    }
592
593    // Check if this is an axis-aligned rectangle (common case for boundaries)
594    if polygon.len() == 4 {
595        let (min_x, min_y, max_x, max_y) = bounding_box(polygon);
596
597        // Check if all vertices are on the bounding box edges (axis-aligned)
598        let is_axis_aligned = polygon.iter().all(|&(x, y)| {
599            ((x - min_x).abs() < 1e-10 || (x - max_x).abs() < 1e-10)
600                && ((y - min_y).abs() < 1e-10 || (y - max_y).abs() < 1e-10)
601        });
602
603        if is_axis_aligned {
604            // Simple shrink for axis-aligned rectangle
605            let new_min_x = min_x + offset;
606            let new_min_y = min_y + offset;
607            let new_max_x = max_x - offset;
608            let new_max_y = max_y - offset;
609
610            // Check if still valid
611            if new_min_x >= new_max_x || new_min_y >= new_max_y {
612                return Err(Error::invalid_geometry(
613                    None,
614                    "Offset polygon collapsed".into(),
615                ));
616            }
617
618            return Ok(vec![
619                (new_min_x, new_min_y),
620                (new_max_x, new_min_y),
621                (new_max_x, new_max_y),
622                (new_min_x, new_max_y),
623            ]);
624        }
625    }
626
627    // Any other polygon: a true inward offset. If the offset splits the
628    // polygon, keep the largest piece — the inner-fit polygon is built from a
629    // single boundary ring, and dropping a smaller piece only forgoes positions.
630    crate::polygon_ops::offset_polygon(polygon, -offset)
631        .into_iter()
632        .max_by(|p, q| {
633            signed_area(p)
634                .abs()
635                .partial_cmp(&signed_area(q).abs())
636                .unwrap_or(std::cmp::Ordering::Equal)
637        })
638        .filter(|ring| signed_area(ring).abs() > 1e-10)
639        .ok_or_else(|| Error::invalid_geometry(None, "Offset polygon collapsed".into()))
640}
641
642/// Computes bounding box of a polygon.
643fn bounding_box(polygon: &[(f64, f64)]) -> (f64, f64, f64, f64) {
644    let mut min_x = f64::INFINITY;
645    let mut min_y = f64::INFINITY;
646    let mut max_x = f64::NEG_INFINITY;
647    let mut max_y = f64::NEG_INFINITY;
648
649    for &(x, y) in polygon {
650        min_x = min_x.min(x);
651        min_y = min_y.min(y);
652        max_x = max_x.max(x);
653        max_y = max_y.max(y);
654    }
655
656    (min_x, min_y, max_x, max_y)
657}
658
659/// Computes NFP for two convex polygons using u-geometry's Minkowski sum.
660///
661/// Delegates to u-geometry's O(n+m) rotating calipers algorithm.
662/// For the NFP, computes: A ⊕ (-B) where ⊕ is Minkowski sum.
663fn compute_nfp_convex(stationary: &[(f64, f64)], orbiting: &[(f64, f64)]) -> Result<Nfp> {
664    use u_nesting_core::geom::minkowski::nfp_convex;
665
666    let polygon = nfp_convex(stationary, orbiting);
667    Ok(Nfp::from_polygon(polygon))
668}
669
670/// Computes Minkowski sum of two convex polygons via u-geometry.
671///
672/// Time complexity: O(n + m) where n, m are vertex counts.
673fn compute_minkowski_sum_convex(poly_a: &[(f64, f64)], poly_b: &[(f64, f64)]) -> Result<Nfp> {
674    use u_nesting_core::geom::minkowski::minkowski_sum_convex;
675
676    let polygon = minkowski_sum_convex(poly_a, poly_b);
677    Ok(Nfp::from_polygon(polygon))
678}
679
680/// Computes NFP for non-convex polygons using convex decomposition + union.
681///
682/// The algorithm:
683/// 1. Decompose both polygons into convex parts (using triangulation)
684/// 2. Compute pairwise Minkowski sums of convex parts
685/// 3. Union all partial results using `i_overlay`
686fn compute_nfp_general(
687    stat_exterior: &[(f64, f64)],
688    rotated_orbiting: &[(f64, f64)],
689) -> Result<Nfp> {
690    // Triangulate both polygons into convex parts
691    let stat_triangles = triangulate_polygon(stat_exterior);
692    let orb_triangles = triangulate_polygon(rotated_orbiting);
693
694    if stat_triangles.is_empty() || orb_triangles.is_empty() {
695        // Fall back to convex hull approximation
696        let stat_hull = convex_hull_of_points(stat_exterior);
697        let orb_hull = convex_hull_of_points(rotated_orbiting);
698        let reflected: Vec<(f64, f64)> = orb_hull.iter().map(|&(x, y)| (-x, -y)).collect();
699        return compute_minkowski_sum_convex(&stat_hull, &reflected);
700    }
701
702    // Compute pairwise Minkowski sums in parallel
703    // Create all pairs for parallel processing
704    let pairs: Vec<_> = stat_triangles
705        .iter()
706        .flat_map(|stat_tri| {
707            orb_triangles
708                .iter()
709                .map(move |orb_tri| (stat_tri.clone(), orb_tri.clone()))
710        })
711        .collect();
712
713    #[cfg(feature = "parallel")]
714    let partial_nfps: Vec<Vec<(f64, f64)>> = pairs
715        .par_iter()
716        .flat_map(|(stat_tri, orb_tri)| {
717            let reflected: Vec<(f64, f64)> = orb_tri.iter().map(|&(x, y)| (-x, -y)).collect();
718            if let Ok(nfp) = compute_minkowski_sum_convex(stat_tri, &reflected) {
719                nfp.polygons
720                    .into_iter()
721                    .filter(|polygon| polygon.len() >= 3)
722                    .collect::<Vec<_>>()
723            } else {
724                Vec::new()
725            }
726        })
727        .collect();
728    #[cfg(not(feature = "parallel"))]
729    let partial_nfps: Vec<Vec<(f64, f64)>> = pairs
730        .iter()
731        .flat_map(|(stat_tri, orb_tri)| {
732            let reflected: Vec<(f64, f64)> = orb_tri.iter().map(|&(x, y)| (-x, -y)).collect();
733            if let Ok(nfp) = compute_minkowski_sum_convex(stat_tri, &reflected) {
734                nfp.polygons
735                    .into_iter()
736                    .filter(|polygon| polygon.len() >= 3)
737                    .collect::<Vec<_>>()
738            } else {
739                Vec::new()
740            }
741        })
742        .collect();
743
744    if partial_nfps.is_empty() {
745        // Fall back to convex hull
746        let stat_hull = convex_hull_of_points(stat_exterior);
747        let orb_hull = convex_hull_of_points(rotated_orbiting);
748        let reflected: Vec<(f64, f64)> = orb_hull.iter().map(|&(x, y)| (-x, -y)).collect();
749        return compute_minkowski_sum_convex(&stat_hull, &reflected);
750    }
751
752    // Union all partial NFPs using i_overlay
753    union_polygons(&partial_nfps)
754}
755
756/// Triangulates a polygon into convex parts (ear clipping algorithm).
757fn triangulate_polygon(polygon: &[(f64, f64)]) -> Vec<Vec<(f64, f64)>> {
758    if polygon.len() < 3 {
759        return Vec::new();
760    }
761
762    // For convex polygons, just return the polygon itself
763    if is_polygon_convex(polygon) {
764        return vec![polygon.to_vec()];
765    }
766
767    // Simple ear-clipping triangulation
768    let mut vertices: Vec<(f64, f64)> = ensure_ccw(polygon);
769    let mut triangles = Vec::new();
770
771    while vertices.len() > 3 {
772        let n = vertices.len();
773        let mut ear_found = false;
774
775        for i in 0..n {
776            let prev = (i + n - 1) % n;
777            let next = (i + 1) % n;
778
779            // Check if this is an ear (convex vertex with no other vertices inside)
780            if is_ear(&vertices, prev, i, next) {
781                triangles.push(vec![vertices[prev], vertices[i], vertices[next]]);
782                vertices.remove(i);
783                ear_found = true;
784                break;
785            }
786        }
787
788        if !ear_found {
789            // No ear found, polygon might be degenerate
790            // Fall back to returning the convex hull
791            return vec![convex_hull_of_points(polygon)];
792        }
793    }
794
795    if vertices.len() == 3 {
796        triangles.push(vertices);
797    }
798
799    triangles
800}
801
802/// Checks if a point is strictly inside a triangle using robust predicates.
803///
804/// Uses robust orientation tests to correctly handle near-degenerate cases.
805fn point_in_triangle_robust(p: (f64, f64), a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> bool {
806    let o1 = orient2d_filtered(a, b, p);
807    let o2 = orient2d_filtered(b, c, p);
808    let o3 = orient2d_filtered(c, a, p);
809
810    // Point is strictly inside if all orientations are the same
811    // (all CCW or all CW) and none are collinear
812    (o1 == Orientation::CounterClockwise
813        && o2 == Orientation::CounterClockwise
814        && o3 == Orientation::CounterClockwise)
815        || (o1 == Orientation::Clockwise
816            && o2 == Orientation::Clockwise
817            && o3 == Orientation::Clockwise)
818}
819
820/// Checks if vertex i forms an ear in the polygon.
821///
822/// Uses robust geometric predicates for numerical stability.
823fn is_ear(vertices: &[(f64, f64)], prev: usize, curr: usize, next: usize) -> bool {
824    let a = vertices[prev];
825    let b = vertices[curr];
826    let c = vertices[next];
827
828    // Check if the vertex is convex (turn left in CCW polygon)
829    // Using robust orientation test instead of cross product
830    let orientation = orient2d_filtered(a, b, c);
831    if !orientation.is_ccw() {
832        return false; // Reflex or collinear vertex, not an ear
833    }
834
835    // Check if any other vertex is inside this triangle
836    for (i, &p) in vertices.iter().enumerate() {
837        if i == prev || i == curr || i == next {
838            continue;
839        }
840        if point_in_triangle_robust(p, a, b, c) {
841            return false;
842        }
843    }
844
845    true
846}
847
848/// Unions multiple polygons using i_overlay.
849fn union_polygons(polygons: &[Vec<(f64, f64)>]) -> Result<Nfp> {
850    if polygons.is_empty() {
851        return Ok(Nfp::new());
852    }
853
854    if polygons.len() == 1 {
855        return Ok(Nfp::from_polygon(polygons[0].clone()));
856    }
857
858    // Start with the first polygon
859    let mut result: Vec<Vec<[f64; 2]>> = vec![polygons[0].iter().map(|&(x, y)| [x, y]).collect()];
860
861    // Union with each subsequent polygon
862    for polygon in &polygons[1..] {
863        let clip: Vec<[f64; 2]> = polygon.iter().map(|&(x, y)| [x, y]).collect();
864
865        // Perform union using i_overlay
866        let shapes = result.overlay(&[clip], OverlayRule::Union, FillRule::NonZero);
867
868        // Convert shapes back to our format
869        result = Vec::new();
870        for shape in shapes {
871            for contour in shape {
872                if contour.len() >= 3 {
873                    result.push(contour);
874                }
875            }
876        }
877
878        if result.is_empty() {
879            // Union failed, continue with remaining polygons
880            continue;
881        }
882    }
883
884    // Convert back to our Nfp format
885    let nfp_polygons: Vec<Vec<(f64, f64)>> = result
886        .into_iter()
887        .map(|contour| contour.into_iter().map(|[x, y]| (x, y)).collect())
888        .collect();
889
890    if nfp_polygons.is_empty() {
891        // Fall back to returning the first polygon
892        return Ok(Nfp::from_polygon(polygons[0].clone()));
893    }
894
895    Ok(Nfp::from_polygons(nfp_polygons))
896}
897
898// ============================================================================
899// Helper functions
900// ============================================================================
901
902/// Rotates a polygon around the origin by the given angle (in radians).
903fn rotate_polygon(polygon: &[(f64, f64)], angle: f64) -> Vec<(f64, f64)> {
904    if angle.abs() < 1e-10 {
905        return polygon.to_vec();
906    }
907
908    let cos_a = angle.cos();
909    let sin_a = angle.sin();
910
911    polygon
912        .iter()
913        .map(|&(x, y)| (x * cos_a - y * sin_a, x * sin_a + y * cos_a))
914        .collect()
915}
916
917/// Checks if a polygon is convex using robust orientation tests.
918fn is_polygon_convex(polygon: &[(f64, f64)]) -> bool {
919    geom_polygon::is_convex(polygon)
920}
921
922/// Ensures polygon vertices are in counter-clockwise order.
923fn ensure_ccw(polygon: &[(f64, f64)]) -> Vec<(f64, f64)> {
924    geom_polygon::ensure_ccw(polygon)
925}
926
927/// Computes the signed area of a polygon.
928/// Positive for counter-clockwise, negative for clockwise.
929fn signed_area(polygon: &[(f64, f64)]) -> f64 {
930    geom_polygon::signed_area(polygon)
931}
932
933/// Computes convex hull of a set of points.
934fn convex_hull_of_points(points: &[(f64, f64)]) -> Vec<(f64, f64)> {
935    geom_polygon::convex_hull(points)
936}
937
938// ============================================================================
939// NFP-guided placement helpers
940// ============================================================================
941
942/// Checks if a point is inside a polygon (using ray casting algorithm).
943pub fn point_in_polygon(point: (f64, f64), polygon: &[(f64, f64)]) -> bool {
944    let (px, py) = point;
945    let n = polygon.len();
946    let mut inside = false;
947
948    let mut j = n - 1;
949    for i in 0..n {
950        let (xi, yi) = polygon[i];
951        let (xj, yj) = polygon[j];
952
953        if ((yi > py) != (yj > py)) && (px < (xj - xi) * (py - yi) / (yj - yi) + xi) {
954            inside = !inside;
955        }
956        j = i;
957    }
958
959    inside
960}
961
962/// Checks if a point is outside all given NFP polygons (not overlapping any placed piece).
963pub fn point_outside_all_nfps(point: (f64, f64), nfps: &[&Nfp]) -> bool {
964    for nfp in nfps {
965        for polygon in &nfp.polygons {
966            if point_in_polygon(point, polygon) {
967                return false;
968            }
969        }
970    }
971    true
972}
973
974/// Checks if a point is on the boundary of a polygon (not strictly inside or outside).
975fn point_on_polygon_boundary(point: (f64, f64), polygon: &[(f64, f64)]) -> bool {
976    let (px, py) = point;
977    let n = polygon.len();
978    const EPS: f64 = 1e-10;
979
980    for i in 0..n {
981        let (x1, y1) = polygon[i];
982        let (x2, y2) = polygon[(i + 1) % n];
983
984        // Check if point is on this edge segment
985        // Using parametric form: P = P1 + t*(P2-P1), 0 <= t <= 1
986        let dx = x2 - x1;
987        let dy = y2 - y1;
988        let len_sq = dx * dx + dy * dy;
989
990        if len_sq < EPS * EPS {
991            // Degenerate edge - check if point is at vertex
992            if (px - x1).abs() < EPS && (py - y1).abs() < EPS {
993                return true;
994            }
995            continue;
996        }
997
998        // Project point onto line
999        let t = ((px - x1) * dx + (py - y1) * dy) / len_sq;
1000
1001        // Check if projection is within segment
1002        if (-EPS..=1.0 + EPS).contains(&t) {
1003            // Check distance from line
1004            let proj_x = x1 + t * dx;
1005            let proj_y = y1 + t * dy;
1006            let dist_sq = (px - proj_x).powi(2) + (py - proj_y).powi(2);
1007
1008            if dist_sq < EPS * EPS {
1009                return true;
1010            }
1011        }
1012    }
1013
1014    false
1015}
1016
1017/// The boundary axis a layout should use as little of — the strip's length.
1018///
1019/// Strip packing fixes one dimension and minimises the other. For a boundary
1020/// this is its longer side (an open-ended strip is always long in `y`), the
1021/// same axis the solvers use to compare layouts. Placement fills across the
1022/// other axis before advancing along this one.
1023#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1024pub enum PackingAxis {
1025    /// Minimise the extent along `x`; fill each column in `y` first.
1026    X,
1027    /// Minimise the extent along `y`; fill each row in `x` first.
1028    Y,
1029}
1030
1031impl PackingAxis {
1032    /// The length axis of `boundary`.
1033    pub fn of(boundary: &crate::boundary::Boundary2D) -> Self {
1034        use u_nesting_core::geometry::Boundary;
1035        if boundary.is_infinite() {
1036            return PackingAxis::Y;
1037        }
1038        let (min, max) = boundary.aabb();
1039        if max[1] - min[1] >= max[0] - min[0] {
1040            PackingAxis::Y
1041        } else {
1042            PackingAxis::X
1043        }
1044    }
1045
1046    /// `(along, across)`: the coordinate along the length axis, then the other.
1047    /// Positions compare lexicographically on this key.
1048    pub fn key(self, point: (f64, f64)) -> (f64, f64) {
1049        match self {
1050            PackingAxis::X => point,
1051            PackingAxis::Y => (point.1, point.0),
1052        }
1053    }
1054
1055    /// Whether `a` is strictly earlier than `b` along the packing order, with
1056    /// `eps` tolerance on each coordinate.
1057    pub fn precedes(self, a: (f64, f64), b: (f64, f64), eps: f64) -> bool {
1058        let (a0, a1) = self.key(a);
1059        let (b0, b1) = self.key(b);
1060        a0 < b0 - eps || (a0 < b0 + eps && a1 < b1 - eps)
1061    }
1062}
1063
1064/// Finds the optimal placement point that minimizes strip length.
1065///
1066/// The valid region is defined as points that are:
1067/// 1. Inside the IFP (Inner-Fit Polygon) - the boundary constraint
1068/// 2. Outside all NFPs (No-Fit Polygons) - not overlapping placed pieces
1069///
1070/// The optimization strategy prioritizes:
1071/// 1. Minimize X coordinate first (to minimize strip length in strip packing)
1072/// 2. Then minimize Y coordinate (pack tightly bottom-to-top)
1073///
1074/// This approach produces shorter strip lengths than the traditional
1075/// "bottom-left" approach which prioritizes Y over X.
1076///
1077/// # Arguments
1078/// * `ifp` - The inner-fit polygon (valid positions within boundary)
1079/// * `nfps` - List of NFPs with already placed pieces
1080/// * `sample_step` - Grid sampling step size (smaller = more accurate but slower)
1081/// * `axis` - The boundary's length axis ([`PackingAxis::of`])
1082///
1083/// # Returns
1084/// The optimal valid point, or None if no valid position exists.
1085pub fn find_bottom_left_placement(
1086    ifp: &Nfp,
1087    nfps: &[&Nfp],
1088    sample_step: f64,
1089    axis: PackingAxis,
1090) -> Option<(f64, f64)> {
1091    if ifp.is_empty() {
1092        return None;
1093    }
1094
1095    // First, try the vertices of the IFP (often optimal positions)
1096    let mut candidates: Vec<(f64, f64)> = Vec::new();
1097
1098    for polygon in &ifp.polygons {
1099        candidates.extend(polygon.iter().copied());
1100    }
1101
1102    // Also collect NFP vertices as potential optimal positions
1103    for nfp in nfps {
1104        for polygon in &nfp.polygons {
1105            candidates.extend(polygon.iter().copied());
1106        }
1107    }
1108
1109    // Where an NFP edge crosses an IFP edge or another NFP edge is a corner of
1110    // the feasible region — the position where a piece touches both. Without
1111    // these, a piece that must sit against a boundary edge and a placed piece
1112    // at once can only land on the next grid point, leaving a gap up to
1113    // `sample_step` wider than required.
1114    for nfp in nfps {
1115        for polygon in &nfp.polygons {
1116            for ifp_polygon in &ifp.polygons {
1117                push_edge_crossings(ifp_polygon, polygon, &mut candidates);
1118            }
1119        }
1120    }
1121    let nfp_polygons: Vec<&Vec<(f64, f64)>> =
1122        nfps.iter().flat_map(|nfp| nfp.polygons.iter()).collect();
1123    for (i, a) in nfp_polygons.iter().enumerate() {
1124        for b in &nfp_polygons[i + 1..] {
1125            push_edge_crossings(a, b, &mut candidates);
1126        }
1127    }
1128
1129    // Find the bounding box of the IFP for grid sampling
1130    let (min_x, min_y, max_x, max_y) = ifp_bounding_box(ifp);
1131
1132    // Add grid sample points
1133    let mut y = min_y;
1134    while y <= max_y {
1135        let mut x = min_x;
1136        while x <= max_x {
1137            candidates.push((x, y));
1138            x += sample_step;
1139        }
1140        y += sample_step;
1141    }
1142
1143    // The first valid candidate in packing order is the answer: least along the
1144    // length axis, then least across it. Checking in that order stops at the
1145    // answer instead of validating every candidate.
1146    candidates.retain(|p| p.0.is_finite() && p.1.is_finite());
1147    candidates.sort_by(|a, b| {
1148        let (a, b) = (axis.key(*a), axis.key(*b));
1149        a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))
1150    });
1151
1152    // A point outside a polygon's bounding box is outside the polygon; most NFPs
1153    // are nowhere near a given candidate.
1154    let boxes: Vec<(f64, f64, f64, f64)> = nfp_polygons
1155        .iter()
1156        .map(|polygon| bounding_box(polygon))
1157        .collect();
1158
1159    candidates.into_iter().find(|&point| {
1160        // Must be inside IFP (including boundary points)
1161        let in_ifp = ifp
1162            .polygons
1163            .iter()
1164            .any(|p| point_in_polygon(point, p) || point_on_polygon_boundary(point, p));
1165        // Must be strictly outside every NFP (touching is allowed, overlapping is not)
1166        in_ifp
1167            && nfp_polygons
1168                .iter()
1169                .zip(&boxes)
1170                .all(|(polygon, &(min_x, min_y, max_x, max_y))| {
1171                    point.0 <= min_x
1172                        || point.0 >= max_x
1173                        || point.1 <= min_y
1174                        || point.1 >= max_y
1175                        || !point_in_polygon(point, polygon)
1176                })
1177    })
1178}
1179
1180/// Appends every point where an edge of ring `a` properly crosses an edge of
1181/// ring `b`. Rings whose bounding boxes are disjoint are skipped outright.
1182fn push_edge_crossings(a: &[(f64, f64)], b: &[(f64, f64)], out: &mut Vec<(f64, f64)>) {
1183    let (a_min_x, a_min_y, a_max_x, a_max_y) = bounding_box(a);
1184    let (b_min_x, b_min_y, b_max_x, b_max_y) = bounding_box(b);
1185    if a_max_x < b_min_x || b_max_x < a_min_x || a_max_y < b_min_y || b_max_y < a_min_y {
1186        return;
1187    }
1188    for i in 0..a.len() {
1189        let (p, p2) = (a[i], a[(i + 1) % a.len()]);
1190        let (rx, ry) = (p2.0 - p.0, p2.1 - p.1);
1191        for j in 0..b.len() {
1192            let (q, q2) = (b[j], b[(j + 1) % b.len()]);
1193            let (sx, sy) = (q2.0 - q.0, q2.1 - q.1);
1194            let denom = rx * sy - ry * sx;
1195            if denom.abs() < 1e-12 {
1196                continue; // parallel: no single crossing point
1197            }
1198            let (qpx, qpy) = (q.0 - p.0, q.1 - p.1);
1199            let t = (qpx * sy - qpy * sx) / denom;
1200            let u = (qpx * ry - qpy * rx) / denom;
1201            if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) {
1202                out.push((p.0 + t * rx, p.1 + t * ry));
1203            }
1204        }
1205    }
1206}
1207
1208/// Computes the bounding box of an NFP.
1209fn ifp_bounding_box(ifp: &Nfp) -> (f64, f64, f64, f64) {
1210    let mut min_x = f64::INFINITY;
1211    let mut min_y = f64::INFINITY;
1212    let mut max_x = f64::NEG_INFINITY;
1213    let mut max_y = f64::NEG_INFINITY;
1214
1215    for polygon in &ifp.polygons {
1216        for &(x, y) in polygon {
1217            min_x = min_x.min(x);
1218            min_y = min_y.min(y);
1219            max_x = max_x.max(x);
1220            max_y = max_y.max(y);
1221        }
1222    }
1223
1224    (min_x, min_y, max_x, max_y)
1225}
1226
1227/// Represents a placed geometry for NFP computation.
1228#[derive(Debug, Clone)]
1229pub struct PlacedGeometry {
1230    /// The original geometry.
1231    pub geometry: Geometry2D,
1232    /// The placement position (x, y).
1233    pub position: (f64, f64),
1234    /// The rotation angle in radians.
1235    pub rotation: f64,
1236    /// Whether this geometry was placed mirrored (`allow_flip` support).
1237    pub mirrored: bool,
1238}
1239
1240impl PlacedGeometry {
1241    /// Creates a new placed geometry (not mirrored — use [`Self::with_mirrored`]
1242    /// for a mirrored placement).
1243    pub fn new(geometry: Geometry2D, position: (f64, f64), rotation: f64) -> Self {
1244        Self {
1245            geometry,
1246            position,
1247            rotation,
1248            mirrored: false,
1249        }
1250    }
1251
1252    /// Sets the mirrored flag.
1253    pub fn with_mirrored(mut self, mirrored: bool) -> Self {
1254        self.mirrored = mirrored;
1255        self
1256    }
1257
1258    /// Returns the translated polygon vertices.
1259    pub fn translated_exterior(&self) -> Vec<(f64, f64)> {
1260        let base = if self.mirrored {
1261            crate::polygon_ops::mirror_polygon(self.geometry.exterior())
1262        } else {
1263            self.geometry.exterior().to_vec()
1264        };
1265        let rotated = rotate_polygon(&base, self.rotation);
1266        rotated
1267            .into_iter()
1268            .map(|(x, y)| (x + self.position.0, y + self.position.1))
1269            .collect()
1270    }
1271}
1272
1273/// Verifies that a geometry at the given position does not overlap with any placed geometries.
1274///
1275/// This uses actual polygon-polygon intersection testing (SAT) rather than
1276/// relying solely on NFP point-in-polygon checks, providing more robust
1277/// collision detection.
1278///
1279/// # Arguments
1280/// * `geometry` - The geometry to be placed
1281/// * `position` - The position (x, y) for the geometry
1282/// * `rotation` - The rotation angle in radians
1283/// * `placed_geometries` - List of already placed geometries
1284///
1285/// # Returns
1286/// `true` if there is NO overlap (placement is valid), `false` if overlap detected
1287pub fn verify_no_overlap(
1288    geometry: &Geometry2D,
1289    position: (f64, f64),
1290    rotation: f64,
1291    placed_geometries: &[PlacedGeometry],
1292) -> bool {
1293    verify_no_overlap_mirrored(geometry, position, rotation, false, placed_geometries)
1294}
1295
1296/// Same as [`verify_no_overlap`], but the geometry being placed can be
1297/// mirrored (`allow_flip` support) — `placed_geometries` already carries
1298/// each placed piece's own mirror state via [`PlacedGeometry::translated_exterior`].
1299pub fn verify_no_overlap_mirrored(
1300    geometry: &Geometry2D,
1301    position: (f64, f64),
1302    rotation: f64,
1303    mirrored: bool,
1304    placed_geometries: &[PlacedGeometry],
1305) -> bool {
1306    use crate::nfp_sliding::polygons_overlap;
1307
1308    // Get the transformed polygon for the geometry being placed
1309    let base = if mirrored {
1310        crate::polygon_ops::mirror_polygon(geometry.exterior())
1311    } else {
1312        geometry.exterior().to_vec()
1313    };
1314    let rotated = rotate_polygon(&base, rotation);
1315    let transformed: Vec<(f64, f64)> = rotated
1316        .into_iter()
1317        .map(|(x, y)| (x + position.0, y + position.1))
1318        .collect();
1319
1320    // Check against each placed geometry
1321    for placed in placed_geometries {
1322        let placed_polygon = placed.translated_exterior();
1323
1324        if polygons_overlap(&transformed, &placed_polygon) {
1325            return false; // Overlap detected
1326        }
1327    }
1328
1329    true // No overlap
1330}
1331
1332// ============================================================================
1333// NFP Cache
1334// ============================================================================
1335
1336/// Cache key for NFP lookups.
1337#[derive(Debug, Clone, Hash, PartialEq, Eq)]
1338struct NfpCacheKey {
1339    geometry_a: String,
1340    geometry_b: String,
1341    rotation_millideg: i32, // Rotation in millidegrees for integer key
1342    // Mirror state of each side (`allow_flip` support) — a mirrored and an
1343    // unmirrored NFP for the same (ids, rotation) are different polygons and
1344    // must not collide in the cache.
1345    mirror_a: bool,
1346    mirror_b: bool,
1347}
1348
1349impl NfpCacheKey {
1350    fn new_mirrored(
1351        id_a: &str,
1352        id_b: &str,
1353        rotation_rad: f64,
1354        mirror_a: bool,
1355        mirror_b: bool,
1356    ) -> Self {
1357        // Convert radians to millidegrees for integer key
1358        let rotation_millideg = ((rotation_rad * 180.0 / PI) * 1000.0).round() as i32;
1359        Self {
1360            geometry_a: id_a.to_string(),
1361            geometry_b: id_b.to_string(),
1362            rotation_millideg,
1363            mirror_a,
1364            mirror_b,
1365        }
1366    }
1367}
1368
1369/// Thread-safe NFP cache for storing precomputed NFPs.
1370#[derive(Debug)]
1371pub struct NfpCache {
1372    cache: RwLock<HashMap<NfpCacheKey, Arc<Nfp>>>,
1373    max_size: usize,
1374}
1375
1376impl NfpCache {
1377    /// Creates a new NFP cache with default capacity (1000 entries).
1378    pub fn new() -> Self {
1379        Self::with_capacity(1000)
1380    }
1381
1382    /// Creates a new NFP cache with specified capacity.
1383    pub fn with_capacity(max_size: usize) -> Self {
1384        Self {
1385            cache: RwLock::new(HashMap::new()),
1386            max_size,
1387        }
1388    }
1389
1390    /// Gets a cached NFP or computes and caches it.
1391    ///
1392    /// # Arguments
1393    /// * `key` - Tuple of (geometry_id_a, geometry_id_b, rotation_in_radians)
1394    /// * `compute` - Function to compute the NFP if not cached
1395    pub fn get_or_compute<F>(&self, key: (&str, &str, f64), compute: F) -> Result<Arc<Nfp>>
1396    where
1397        F: FnOnce() -> Result<Nfp>,
1398    {
1399        self.get_or_compute_mirrored((key.0, key.1, key.2, false, false), compute)
1400    }
1401
1402    /// Same as [`Self::get_or_compute`], but the key also carries each side's
1403    /// mirror state (`allow_flip` support) so a mirrored and an unmirrored
1404    /// NFP for the same (ids, rotation) don't collide.
1405    ///
1406    /// # Arguments
1407    /// * `key` - Tuple of (geometry_id_a, geometry_id_b, rotation_in_radians,
1408    ///   mirror_a, mirror_b)
1409    pub fn get_or_compute_mirrored<F>(
1410        &self,
1411        key: (&str, &str, f64, bool, bool),
1412        compute: F,
1413    ) -> Result<Arc<Nfp>>
1414    where
1415        F: FnOnce() -> Result<Nfp>,
1416    {
1417        let cache_key = NfpCacheKey::new_mirrored(key.0, key.1, key.2, key.3, key.4);
1418
1419        // Try to get from cache first (read lock)
1420        {
1421            let cache = self.cache.read().map_err(|e| {
1422                Error::Internal(format!("Failed to acquire cache read lock: {}", e))
1423            })?;
1424            if let Some(nfp) = cache.get(&cache_key) {
1425                return Ok(Arc::clone(nfp));
1426            }
1427        }
1428
1429        // Compute the NFP
1430        let nfp = Arc::new(compute()?);
1431
1432        // Store in cache (write lock)
1433        {
1434            let mut cache = self.cache.write().map_err(|e| {
1435                Error::Internal(format!("Failed to acquire cache write lock: {}", e))
1436            })?;
1437
1438            // Simple eviction: if at capacity, clear half the cache
1439            if cache.len() >= self.max_size {
1440                let keys_to_remove: Vec<_> =
1441                    cache.keys().take(self.max_size / 2).cloned().collect();
1442                for key in keys_to_remove {
1443                    cache.remove(&key);
1444                }
1445            }
1446
1447            cache.insert(cache_key, Arc::clone(&nfp));
1448        }
1449
1450        Ok(nfp)
1451    }
1452
1453    /// Returns the number of cached entries.
1454    pub fn len(&self) -> usize {
1455        self.cache.read().map(|c| c.len()).unwrap_or(0)
1456    }
1457
1458    /// Returns true if the cache is empty.
1459    pub fn is_empty(&self) -> bool {
1460        self.len() == 0
1461    }
1462
1463    /// Clears the cache.
1464    pub fn clear(&self) {
1465        if let Ok(mut cache) = self.cache.write() {
1466            cache.clear();
1467        }
1468    }
1469}
1470
1471impl Default for NfpCache {
1472    fn default() -> Self {
1473        Self::new()
1474    }
1475}
1476
1477#[cfg(test)]
1478mod tests {
1479    use super::*;
1480    use approx::assert_relative_eq;
1481
1482    fn rect(w: f64, h: f64) -> Vec<(f64, f64)> {
1483        vec![(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)]
1484    }
1485
1486    fn triangle() -> Vec<(f64, f64)> {
1487        vec![(0.0, 0.0), (10.0, 0.0), (5.0, 10.0)]
1488    }
1489
1490    #[test]
1491    fn test_is_polygon_convex() {
1492        // Square is convex
1493        assert!(is_polygon_convex(&rect(10.0, 10.0)));
1494
1495        // Triangle is convex
1496        assert!(is_polygon_convex(&triangle()));
1497
1498        // L-shape is not convex
1499        let l_shape = vec![
1500            (0.0, 0.0),
1501            (10.0, 0.0),
1502            (10.0, 5.0),
1503            (5.0, 5.0),
1504            (5.0, 10.0),
1505            (0.0, 10.0),
1506        ];
1507        assert!(!is_polygon_convex(&l_shape));
1508    }
1509
1510    #[test]
1511    fn test_signed_area() {
1512        // CCW square has positive area
1513        let ccw_square = rect(10.0, 10.0);
1514        assert!(signed_area(&ccw_square) > 0.0);
1515        assert_relative_eq!(signed_area(&ccw_square).abs(), 100.0, epsilon = 1e-10);
1516
1517        // CW square has negative area
1518        let cw_square: Vec<_> = ccw_square.into_iter().rev().collect();
1519        assert!(signed_area(&cw_square) < 0.0);
1520    }
1521
1522    #[test]
1523    fn test_rotate_polygon() {
1524        let square = rect(10.0, 10.0);
1525
1526        // No rotation
1527        let rotated = rotate_polygon(&square, 0.0);
1528        assert_eq!(rotated.len(), square.len());
1529
1530        // 90 degree rotation
1531        let rotated = rotate_polygon(&[(1.0, 0.0)], PI / 2.0);
1532        assert_relative_eq!(rotated[0].0, 0.0, epsilon = 1e-10);
1533        assert_relative_eq!(rotated[0].1, 1.0, epsilon = 1e-10);
1534    }
1535
1536    /// A chiral L-shape: no reflection maps it back onto itself (unlike a
1537    /// square-notched square, whose diagonal happens to be a symmetry axis),
1538    /// so mirroring it must produce a genuinely different polygon — the
1539    /// right fixture to prove the mirror flag isn't a silent no-op.
1540    fn chiral_l() -> Geometry2D {
1541        Geometry2D::l_shape("L", 30.0, 20.0, 20.0, 10.0)
1542    }
1543
1544    #[test]
1545    fn test_nfp_mirror_orbiting_changes_result() {
1546        let stationary = Geometry2D::rectangle("S", 100.0, 100.0);
1547        let orbiting = chiral_l();
1548
1549        let unmirrored = compute_nfp_mirrored(&stationary, &orbiting, 0.0, false, false).unwrap();
1550        let mirrored = compute_nfp_mirrored(&stationary, &orbiting, 0.0, false, true).unwrap();
1551
1552        assert!(!unmirrored.is_empty());
1553        assert!(!mirrored.is_empty());
1554        assert_ne!(
1555            unmirrored.polygons, mirrored.polygons,
1556            "mirroring the orbiting polygon must change the NFP for a chiral shape"
1557        );
1558
1559        // Reflection is area-preserving: the two NFPs must cover equal area
1560        // even though their vertex layout differs.
1561        let unmirrored_area: f64 = unmirrored
1562            .polygons
1563            .iter()
1564            .map(|p| signed_area(p).abs())
1565            .sum();
1566        let mirrored_area: f64 = mirrored.polygons.iter().map(|p| signed_area(p).abs()).sum();
1567        assert_relative_eq!(unmirrored_area, mirrored_area, epsilon = 1e-6);
1568    }
1569
1570    #[test]
1571    fn test_nfp_mirror_stationary_changes_result() {
1572        let stationary = chiral_l();
1573        let orbiting = Geometry2D::rectangle("O", 5.0, 5.0);
1574
1575        let unmirrored = compute_nfp_mirrored(&stationary, &orbiting, 0.0, false, false).unwrap();
1576        let mirrored = compute_nfp_mirrored(&stationary, &orbiting, 0.0, true, false).unwrap();
1577
1578        assert!(!unmirrored.is_empty());
1579        assert!(!mirrored.is_empty());
1580        assert_ne!(
1581            unmirrored.polygons, mirrored.polygons,
1582            "mirroring the stationary polygon must change the NFP for a chiral shape"
1583        );
1584    }
1585
1586    #[test]
1587    fn test_compute_nfp_unchanged_by_new_mirror_plumbing() {
1588        // `compute_nfp` (the pre-existing public entry point) must still
1589        // behave exactly as before — it's `compute_nfp_mirrored(.., false,
1590        // false)` under the hood now, not a new code path.
1591        let a = Geometry2D::rectangle("A", 10.0, 10.0);
1592        let b = Geometry2D::rectangle("B", 5.0, 5.0);
1593        let via_plain = compute_nfp(&a, &b, 0.0).unwrap();
1594        let via_mirrored = compute_nfp_mirrored(&a, &b, 0.0, false, false).unwrap();
1595        assert_eq!(via_plain.polygons, via_mirrored.polygons);
1596    }
1597
1598    #[test]
1599    fn test_ifp_mirror_changes_result() {
1600        let boundary = rect(100.0, 100.0);
1601        let geom = chiral_l();
1602
1603        let unmirrored =
1604            compute_ifp_with_margin_and_mirror(&boundary, &geom, 0.0, 0.0, false).unwrap();
1605        let mirrored =
1606            compute_ifp_with_margin_and_mirror(&boundary, &geom, 0.0, 0.0, true).unwrap();
1607
1608        assert!(!unmirrored.is_empty());
1609        assert!(!mirrored.is_empty());
1610        assert_ne!(
1611            unmirrored.polygons, mirrored.polygons,
1612            "mirroring the geometry must change its IFP within the boundary for a chiral shape"
1613        );
1614    }
1615
1616    #[test]
1617    fn test_compute_ifp_with_margin_unchanged_by_new_mirror_plumbing() {
1618        let boundary = rect(100.0, 100.0);
1619        let geom = Geometry2D::rectangle("G", 10.0, 10.0);
1620        let via_plain = compute_ifp_with_margin(&boundary, &geom, 0.0, 5.0).unwrap();
1621        let via_mirrored =
1622            compute_ifp_with_margin_and_mirror(&boundary, &geom, 0.0, 5.0, false).unwrap();
1623        assert_eq!(via_plain.polygons, via_mirrored.polygons);
1624    }
1625
1626    #[test]
1627    fn test_nfp_cache_mirror_flags_distinguish_entries() {
1628        let cache = NfpCache::new();
1629        let mut calls = 0;
1630
1631        let unmirrored = cache
1632            .get_or_compute_mirrored(("A", "B", 0.0, false, false), || {
1633                calls += 1;
1634                Ok(Nfp::from_polygon(rect(1.0, 1.0)))
1635            })
1636            .unwrap();
1637
1638        // Same ids/rotation, different mirror flags: must NOT reuse the
1639        // `(false, false)` entry — a distinct cache slot, so `compute` runs
1640        // again rather than silently returning the wrong-orientation NFP.
1641        let mirrored = cache
1642            .get_or_compute_mirrored(("A", "B", 0.0, true, false), || {
1643                calls += 1;
1644                Ok(Nfp::from_polygon(rect(2.0, 2.0)))
1645            })
1646            .unwrap();
1647
1648        assert_eq!(
1649            calls, 2,
1650            "distinct mirror flags must both invoke compute, not share a slot"
1651        );
1652        assert_ne!(unmirrored.polygons, mirrored.polygons);
1653
1654        // Re-querying the first key must hit the cache (no third compute call).
1655        let unmirrored_again = cache
1656            .get_or_compute_mirrored(("A", "B", 0.0, false, false), || {
1657                calls += 1;
1658                Ok(Nfp::from_polygon(rect(99.0, 99.0)))
1659            })
1660            .unwrap();
1661        assert_eq!(calls, 2, "re-querying an existing key must hit the cache");
1662        assert_eq!(unmirrored.polygons, unmirrored_again.polygons);
1663    }
1664
1665    #[test]
1666    fn test_nfp_two_squares() {
1667        let a = Geometry2D::rectangle("A", 10.0, 10.0);
1668        let b = Geometry2D::rectangle("B", 5.0, 5.0);
1669
1670        let nfp = compute_nfp(&a, &b, 0.0).unwrap();
1671
1672        assert!(!nfp.is_empty());
1673        assert_eq!(nfp.polygons.len(), 1);
1674
1675        // NFP of two axis-aligned rectangles should have 4 vertices
1676        // NFP dimensions should be (10+5) x (10+5) = 15 x 15
1677        let polygon = &nfp.polygons[0];
1678        assert!(polygon.len() >= 4);
1679    }
1680
1681    #[test]
1682    fn test_nfp_with_rotation() {
1683        let a = Geometry2D::rectangle("A", 10.0, 10.0);
1684        let b = Geometry2D::rectangle("B", 5.0, 5.0);
1685
1686        // Compute with 45 degree rotation
1687        let nfp = compute_nfp(&a, &b, PI / 4.0).unwrap();
1688
1689        assert!(!nfp.is_empty());
1690        // Rotated NFP should have more vertices due to the octagonal shape
1691    }
1692
1693    #[test]
1694    fn test_ifp_square_in_boundary() {
1695        let boundary = rect(100.0, 100.0);
1696        let geom = Geometry2D::rectangle("G", 10.0, 10.0);
1697
1698        let ifp = compute_ifp(&boundary, &geom, 0.0).unwrap();
1699
1700        assert!(!ifp.is_empty());
1701        // IFP should be a rectangle of size (100-10) x (100-10) = 90 x 90
1702        // Valid placements: X in [0, 90], Y in [0, 90]
1703        let polygon = &ifp.polygons[0];
1704        let (min_x, min_y, max_x, max_y) = bounding_box(polygon);
1705        assert_relative_eq!(min_x, 0.0, epsilon = 1e-10);
1706        assert_relative_eq!(min_y, 0.0, epsilon = 1e-10);
1707        assert_relative_eq!(max_x, 90.0, epsilon = 1e-10);
1708        assert_relative_eq!(max_y, 90.0, epsilon = 1e-10);
1709    }
1710
1711    #[test]
1712    fn test_ifp_bounds_correct() {
1713        // Test case from failing test: 25x25 rectangle in 100x50 boundary
1714        let boundary = rect(100.0, 50.0);
1715        let geom = Geometry2D::rectangle("R", 25.0, 25.0);
1716
1717        let ifp = compute_ifp(&boundary, &geom, 0.0).unwrap();
1718
1719        assert!(!ifp.is_empty());
1720        // IFP should be [0, 75] x [0, 25]
1721        let polygon = &ifp.polygons[0];
1722        let (min_x, min_y, max_x, max_y) = bounding_box(polygon);
1723        assert_relative_eq!(min_x, 0.0, epsilon = 1e-10);
1724        assert_relative_eq!(min_y, 0.0, epsilon = 1e-10);
1725        assert_relative_eq!(max_x, 75.0, epsilon = 1e-10);
1726        assert_relative_eq!(max_y, 25.0, epsilon = 1e-10);
1727
1728        // Positions at (0,0), (25,0), (50,0), (75,0) should all be valid
1729        assert!(point_in_polygon((0.0, 0.0), polygon) || point_on_boundary((0.0, 0.0), polygon));
1730        assert!(point_in_polygon((25.0, 0.0), polygon) || point_on_boundary((25.0, 0.0), polygon));
1731        assert!(point_in_polygon((50.0, 0.0), polygon) || point_on_boundary((50.0, 0.0), polygon));
1732        assert!(point_in_polygon((75.0, 0.0), polygon) || point_on_boundary((75.0, 0.0), polygon));
1733    }
1734
1735    /// Helper to check if point is on polygon boundary
1736    fn point_on_boundary(point: (f64, f64), polygon: &[(f64, f64)]) -> bool {
1737        let (px, py) = point;
1738        let n = polygon.len();
1739        for i in 0..n {
1740            let (x1, y1) = polygon[i];
1741            let (x2, y2) = polygon[(i + 1) % n];
1742            // Check if point is on line segment
1743            let d1 = ((px - x1).powi(2) + (py - y1).powi(2)).sqrt();
1744            let d2 = ((px - x2).powi(2) + (py - y2).powi(2)).sqrt();
1745            let d_total = ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt();
1746            if (d1 + d2 - d_total).abs() < 1e-10 {
1747                return true;
1748            }
1749        }
1750        false
1751    }
1752
1753    #[test]
1754    fn test_nfp_same_size_rectangles() {
1755        // Two same-size rectangles: NFP should be twice the size
1756        let a = Geometry2D::rectangle("A", 25.0, 25.0);
1757        let b = Geometry2D::rectangle("B", 25.0, 25.0);
1758
1759        let nfp = compute_nfp(&a, &b, 0.0).unwrap();
1760        assert!(!nfp.is_empty());
1761
1762        let polygon = &nfp.polygons[0];
1763        let (min_x, min_y, max_x, max_y) = bounding_box(polygon);
1764        // NFP should span from -25 to +25 in each dimension = 50x50
1765        // Actually depends on reference point. Let's check actual dimensions.
1766        let width = max_x - min_x;
1767        let height = max_y - min_y;
1768        eprintln!("NFP dimensions: {}x{}", width, height);
1769        eprintln!(
1770            "NFP bounds: ({}, {}) to ({}, {})",
1771            min_x, min_y, max_x, max_y
1772        );
1773        // NFP of two identical rectangles should be 50x50
1774        assert_relative_eq!(width, 50.0, epsilon = 1e-6);
1775        assert_relative_eq!(height, 50.0, epsilon = 1e-6);
1776    }
1777
1778    #[test]
1779    fn test_nfp_cache() {
1780        let cache = NfpCache::new();
1781
1782        let compute_count = std::sync::atomic::AtomicUsize::new(0);
1783
1784        let result1 = cache
1785            .get_or_compute(("A", "B", 0.0), || {
1786                compute_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1787                Ok(Nfp::from_polygon(vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]))
1788            })
1789            .unwrap();
1790
1791        let result2 = cache
1792            .get_or_compute(("A", "B", 0.0), || {
1793                compute_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1794                Ok(Nfp::from_polygon(vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]))
1795            })
1796            .unwrap();
1797
1798        // Should only compute once
1799        assert_eq!(compute_count.load(std::sync::atomic::Ordering::SeqCst), 1);
1800        assert_eq!(result1.polygons, result2.polygons);
1801        assert_eq!(cache.len(), 1);
1802    }
1803
1804    #[test]
1805    fn test_nfp_cache_different_rotations() {
1806        let cache = NfpCache::new();
1807
1808        cache
1809            .get_or_compute(("A", "B", 0.0), || {
1810                Ok(Nfp::from_polygon(vec![(0.0, 0.0), (1.0, 0.0)]))
1811            })
1812            .unwrap();
1813
1814        cache
1815            .get_or_compute(("A", "B", PI / 2.0), || {
1816                Ok(Nfp::from_polygon(vec![(0.0, 0.0), (0.0, 1.0)]))
1817            })
1818            .unwrap();
1819
1820        // Different rotations should be cached separately
1821        assert_eq!(cache.len(), 2);
1822    }
1823
1824    #[test]
1825    fn test_convex_hull_of_points() {
1826        let points = vec![
1827            (0.0, 0.0),
1828            (10.0, 0.0),
1829            (5.0, 5.0), // Interior point
1830            (10.0, 10.0),
1831            (0.0, 10.0),
1832        ];
1833
1834        let hull = convex_hull_of_points(&points);
1835
1836        // Hull should have 4 vertices (square without interior point)
1837        assert_eq!(hull.len(), 4);
1838    }
1839
1840    #[test]
1841    fn test_shrink_polygon_square() {
1842        let square = rect(100.0, 100.0);
1843        let shrunk = shrink_polygon(&square, 10.0).unwrap();
1844
1845        // Should still have 4 vertices
1846        assert_eq!(shrunk.len(), 4);
1847
1848        // The shrunk polygon should be smaller
1849        let original_area = signed_area(&square).abs();
1850        let shrunk_area = signed_area(&shrunk).abs();
1851        assert!(
1852            shrunk_area < original_area,
1853            "shrunk_area ({}) should be < original_area ({})",
1854            shrunk_area,
1855            original_area
1856        );
1857
1858        // Expected area: (100-20)*(100-20) = 6400
1859        // (10.0 offset on each side)
1860        assert_relative_eq!(shrunk_area, 6400.0, epsilon = 1.0);
1861    }
1862
1863    #[test]
1864    fn test_shrink_polygon_collapse() {
1865        let small_square = rect(10.0, 10.0);
1866
1867        // Shrinking by 6 should collapse the 10x10 polygon (becomes 0 or negative)
1868        let result = shrink_polygon(&small_square, 6.0);
1869        assert!(
1870            result.is_err(),
1871            "Polygon should collapse when offset >= width/2"
1872        );
1873    }
1874
1875    #[test]
1876    fn test_ifp_with_margin() {
1877        let boundary = rect(100.0, 100.0);
1878        let geom = Geometry2D::rectangle("G", 10.0, 10.0);
1879
1880        // Without margin
1881        let ifp_no_margin = compute_ifp(&boundary, &geom, 0.0).unwrap();
1882
1883        // With margin
1884        let ifp_with_margin = compute_ifp_with_margin(&boundary, &geom, 0.0, 5.0).unwrap();
1885
1886        assert!(!ifp_no_margin.is_empty());
1887        assert!(!ifp_with_margin.is_empty());
1888
1889        // IFP with margin should be smaller
1890        let (min_x_no, _min_y_no, max_x_no, _max_y_no) = ifp_bounding_box(&ifp_no_margin);
1891        let (min_x_margin, _min_y_margin, max_x_margin, _max_y_margin) =
1892            ifp_bounding_box(&ifp_with_margin);
1893
1894        let width_no = max_x_no - min_x_no;
1895        let width_margin = max_x_margin - min_x_margin;
1896
1897        // Width should be smaller with margin applied
1898        // Without margin: IFP width = 100 - 10 = 90
1899        // With margin 5: effective boundary is 90x90, IFP width = 90 - 10 = 80
1900        assert!(
1901            width_margin < width_no,
1902            "width_margin ({}) should be < width_no ({})",
1903            width_margin,
1904            width_no
1905        );
1906    }
1907
1908    #[test]
1909    fn shrinking_a_non_rectangular_boundary_moves_every_edge_inward_by_the_offset() {
1910        // An L-shaped boundary: its reflex corner and diagonal-free edges were
1911        // pulled toward the vertex centroid, not offset.
1912        let l_boundary = vec![
1913            (0.0, 0.0),
1914            (100.0, 0.0),
1915            (100.0, 40.0),
1916            (40.0, 40.0),
1917            (40.0, 100.0),
1918            (0.0, 100.0),
1919        ];
1920        let shrunk = shrink_polygon(&l_boundary, 10.0).unwrap();
1921        let n = l_boundary.len();
1922        let dist = |p: (f64, f64)| {
1923            (0..n)
1924                .map(|i| {
1925                    let (a, b) = (l_boundary[i], l_boundary[(i + 1) % n]);
1926                    let (dx, dy) = (b.0 - a.0, b.1 - a.1);
1927                    let t = (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / (dx * dx + dy * dy))
1928                        .clamp(0.0, 1.0);
1929                    ((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt()
1930                })
1931                .fold(f64::INFINITY, f64::min)
1932        };
1933        for &v in &shrunk {
1934            assert!(
1935                dist(v) >= 10.0 - 1e-6,
1936                "vertex {v:?} is {} from the boundary",
1937                dist(v)
1938            );
1939        }
1940        // The leg edges sit exactly 10 in (up to the arc allowance).
1941        let min_x = shrunk.iter().map(|p| p.0).fold(f64::MAX, f64::min);
1942        let min_y = shrunk.iter().map(|p| p.1).fold(f64::MAX, f64::min);
1943        assert!((10.0..10.02).contains(&min_x), "left edge at {min_x}");
1944        assert!((10.0..10.02).contains(&min_y), "bottom edge at {min_y}");
1945    }
1946
1947    #[test]
1948    fn test_ifp_margin_boundary_collapse() {
1949        let boundary = rect(20.0, 20.0);
1950
1951        // Margin of 12 would make the effective boundary negative (collapse)
1952        let result = shrink_polygon(&boundary, 12.0);
1953        assert!(
1954            result.is_err(),
1955            "Boundary should collapse with margin >= width/2"
1956        );
1957    }
1958
1959    #[test]
1960    fn test_ifp_margin_large_geometry() {
1961        let boundary = rect(30.0, 30.0);
1962        let geom = Geometry2D::rectangle("G", 20.0, 20.0);
1963
1964        // Without margin: IFP width = 30 - 20 = 10
1965        let ifp_no_margin = compute_ifp(&boundary, &geom, 0.0).unwrap();
1966        let (min_x_no, _, max_x_no, _) = ifp_bounding_box(&ifp_no_margin);
1967        let width_no = max_x_no - min_x_no;
1968
1969        // With margin 5: effective boundary is 20x20, IFP width = 20 - 20 = 0
1970        let ifp_with_margin = compute_ifp_with_margin(&boundary, &geom, 0.0, 5.0).unwrap();
1971        let (min_x_margin, _, max_x_margin, _) = ifp_bounding_box(&ifp_with_margin);
1972        let width_margin = max_x_margin - min_x_margin;
1973
1974        // IFP should be smaller (possibly degenerate) with margin
1975        assert!(
1976            width_margin <= width_no,
1977            "width_margin ({}) should be <= width_no ({})",
1978            width_margin,
1979            width_no
1980        );
1981    }
1982
1983    #[test]
1984    fn test_nfp_non_convex_l_shape() {
1985        // L-shape is not convex
1986        let l_shape = Geometry2D::new("L").with_polygon(vec![
1987            (0.0, 0.0),
1988            (20.0, 0.0),
1989            (20.0, 10.0),
1990            (10.0, 10.0),
1991            (10.0, 20.0),
1992            (0.0, 20.0),
1993        ]);
1994
1995        let small_square = Geometry2D::rectangle("S", 5.0, 5.0);
1996
1997        // Should compute NFP for non-convex polygon
1998        let nfp = compute_nfp(&l_shape, &small_square, 0.0).unwrap();
1999
2000        assert!(!nfp.is_empty());
2001        // NFP should have multiple vertices due to non-convex shape
2002        assert!(nfp.vertex_count() >= 4);
2003    }
2004
2005    #[test]
2006    fn test_triangulate_polygon_convex() {
2007        let square = rect(10.0, 10.0);
2008        let triangles = triangulate_polygon(&square);
2009
2010        // Convex polygon should return itself
2011        assert_eq!(triangles.len(), 1);
2012        assert_eq!(triangles[0].len(), 4);
2013    }
2014
2015    #[test]
2016    fn test_triangulate_polygon_non_convex() {
2017        // L-shape
2018        let l_shape = vec![
2019            (0.0, 0.0),
2020            (20.0, 0.0),
2021            (20.0, 10.0),
2022            (10.0, 10.0),
2023            (10.0, 20.0),
2024            (0.0, 20.0),
2025        ];
2026
2027        let triangles = triangulate_polygon(&l_shape);
2028
2029        // Should triangulate into multiple triangles
2030        assert!(!triangles.is_empty());
2031    }
2032
2033    #[test]
2034    fn test_union_polygons() {
2035        // Two overlapping squares
2036        let poly1 = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
2037        let poly2 = vec![(5.0, 5.0), (15.0, 5.0), (15.0, 15.0), (5.0, 15.0)];
2038
2039        let result = union_polygons(&[poly1, poly2]).unwrap();
2040
2041        assert!(!result.is_empty());
2042        // Union of two overlapping squares should have more than 4 vertices
2043        assert!(result.vertex_count() >= 6);
2044    }
2045
2046    // ========================================================================
2047    // Near-Degenerate Case Tests (Numerical Robustness)
2048    // ========================================================================
2049
2050    #[test]
2051    fn test_convex_near_collinear_vertices() {
2052        // Polygon with nearly collinear vertices that could fail with naive arithmetic
2053        let near_collinear = vec![
2054            (0.0, 0.0),
2055            (1.0, 1e-15), // Nearly on line y=0
2056            (2.0, 0.0),
2057            (2.0, 1.0),
2058            (0.0, 1.0),
2059        ];
2060
2061        // Should handle without crashing
2062        let result = is_polygon_convex(&near_collinear);
2063        // The result depends on numerical precision, but it shouldn't panic
2064        let _ = result; // Just verify it doesn't panic
2065    }
2066
2067    #[test]
2068    fn test_triangulation_near_degenerate() {
2069        // L-shape with vertices very close together
2070        let near_degenerate_l = vec![
2071            (0.0, 0.0),
2072            (10.0, 0.0),
2073            (10.0, 5.0),
2074            (5.0 + 1e-12, 5.0), // Very close to (5, 5)
2075            (5.0, 10.0),
2076            (0.0, 10.0),
2077        ];
2078
2079        // Should triangulate without crashing
2080        let triangles = triangulate_polygon(&near_degenerate_l);
2081
2082        // Should produce at least one triangle
2083        assert!(!triangles.is_empty());
2084    }
2085
2086    #[test]
2087    fn test_nfp_nearly_touching_rectangles() {
2088        // Two rectangles that are nearly touching (gap of 1e-10)
2089        let a = Geometry2D::rectangle("A", 10.0, 10.0);
2090        let b = Geometry2D::rectangle("B", 5.0, 5.0);
2091
2092        // Should compute NFP correctly even with near-degenerate cases
2093        let nfp = compute_nfp(&a, &b, 0.0).unwrap();
2094        assert!(!nfp.is_empty());
2095    }
2096
2097    #[test]
2098    fn test_ifp_geometry_nearly_fills_boundary() {
2099        // Geometry that nearly fills the boundary (leaves very small margin)
2100        let boundary = rect(100.0, 100.0);
2101        let geom = Geometry2D::rectangle("G", 99.9999, 99.9999);
2102
2103        // Should handle without error
2104        let result = compute_ifp(&boundary, &geom, 0.0);
2105
2106        // Either succeeds with a tiny IFP or fails gracefully
2107        match result {
2108            Ok(ifp) => {
2109                // IFP should be very small or a single point
2110                let (min_x, min_y, max_x, max_y) = ifp_bounding_box(&ifp);
2111                let width = max_x - min_x;
2112                let height = max_y - min_y;
2113                assert!(width < 0.001 && height < 0.001);
2114            }
2115            Err(_) => {
2116                // Also acceptable - geometry too large to fit meaningfully
2117            }
2118        }
2119    }
2120
2121    #[test]
2122    fn test_point_in_polygon_on_boundary() {
2123        // Test point exactly on polygon edge
2124        let square = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
2125
2126        // Points on edges
2127        let on_bottom_edge = (5.0, 0.0);
2128        let on_right_edge = (10.0, 5.0);
2129        let on_top_edge = (5.0, 10.0);
2130        let on_left_edge = (0.0, 5.0);
2131
2132        // Ray casting algorithm behavior on boundaries is implementation-defined,
2133        // but it should not crash
2134        let _ = point_in_polygon(on_bottom_edge, &square);
2135        let _ = point_in_polygon(on_right_edge, &square);
2136        let _ = point_in_polygon(on_top_edge, &square);
2137        let _ = point_in_polygon(on_left_edge, &square);
2138    }
2139
2140    #[test]
2141    fn test_point_in_triangle_robust_degenerate() {
2142        // Degenerate triangle (all points collinear)
2143        let a = (0.0, 0.0);
2144        let b = (5.0, 0.0);
2145        let c = (10.0, 0.0);
2146
2147        // Point on the line
2148        let p = (3.0, 0.0);
2149
2150        // Should return false (not inside a degenerate triangle)
2151        assert!(!point_in_triangle_robust(p, a, b, c));
2152    }
2153
2154    #[test]
2155    fn test_ear_detection_with_collinear_points() {
2156        // Polygon with collinear consecutive vertices
2157        let with_collinear = vec![
2158            (0.0, 0.0),
2159            (5.0, 0.0),
2160            (10.0, 0.0), // Collinear with previous two
2161            (10.0, 10.0),
2162            (0.0, 10.0),
2163        ];
2164
2165        // Should handle triangulation without crashing
2166        let triangles = triangulate_polygon(&with_collinear);
2167
2168        // Should produce valid triangles
2169        for triangle in &triangles {
2170            assert!(triangle.len() >= 3);
2171        }
2172    }
2173
2174    #[test]
2175    fn test_nfp_with_very_small_polygon() {
2176        // Very small polygon (micrometer scale)
2177        let tiny = Geometry2D::rectangle("tiny", 1e-6, 1e-6);
2178        let normal = Geometry2D::rectangle("normal", 10.0, 10.0);
2179
2180        // Should compute NFP correctly
2181        let nfp = compute_nfp(&normal, &tiny, 0.0).unwrap();
2182        assert!(!nfp.is_empty());
2183    }
2184
2185    #[test]
2186    fn test_nfp_with_very_large_polygon() {
2187        // Very large polygon (kilometer scale)
2188        let large = Geometry2D::rectangle("large", 1e6, 1e6);
2189        let normal = Geometry2D::rectangle("normal", 100.0, 100.0);
2190
2191        // Should compute NFP correctly
2192        let nfp = compute_nfp(&large, &normal, 0.0).unwrap();
2193        assert!(!nfp.is_empty());
2194    }
2195
2196    #[test]
2197    fn test_signed_area_with_extreme_coordinates() {
2198        // Polygon with very large coordinates
2199        // Note: Standard floating-point arithmetic loses precision at extreme magnitudes.
2200        // This test documents the limitation - for better precision at extreme scales,
2201        // use the robust::signed_area_robust from u_nesting_core.
2202
2203        // Moderate scale - should be accurate
2204        let moderate_coords = vec![
2205            (1e6, 1e6),
2206            (1e6 + 100.0, 1e6),
2207            (1e6 + 100.0, 1e6 + 100.0),
2208            (1e6, 1e6 + 100.0),
2209        ];
2210
2211        let area = signed_area(&moderate_coords);
2212
2213        // Area should be 10000 (100 * 100)
2214        assert_relative_eq!(area.abs(), 10000.0, epsilon = 1.0);
2215    }
2216
2217    #[test]
2218    fn test_ensure_ccw_with_near_zero_area() {
2219        // Polygon with very small area
2220        let tiny_area = vec![(0.0, 0.0), (1e-10, 0.0), (1e-10, 1e-10), (0.0, 1e-10)];
2221
2222        // Should handle without crashing
2223        let ccw = ensure_ccw(&tiny_area);
2224        assert_eq!(ccw.len(), tiny_area.len());
2225    }
2226
2227    // ========================================================================
2228    // NfpMethod Tests
2229    // ========================================================================
2230
2231    #[test]
2232    fn test_nfp_method_default() {
2233        let config = NfpConfig::default();
2234        assert_eq!(config.method, NfpMethod::MinkowskiSum);
2235    }
2236
2237    #[test]
2238    fn test_nfp_method_minkowski_sum() {
2239        let a = Geometry2D::rectangle("A", 10.0, 10.0);
2240        let b = Geometry2D::rectangle("B", 5.0, 5.0);
2241
2242        let nfp = compute_nfp_with_method(&a, &b, 0.0, NfpMethod::MinkowskiSum).unwrap();
2243
2244        assert!(!nfp.is_empty());
2245        assert!(nfp.vertex_count() >= 4);
2246    }
2247
2248    #[test]
2249    fn test_nfp_method_sliding() {
2250        let a = Geometry2D::rectangle("A", 10.0, 10.0);
2251        let b = Geometry2D::rectangle("B", 5.0, 5.0);
2252
2253        let result = compute_nfp_with_method(&a, &b, 0.0, NfpMethod::Sliding);
2254
2255        // Sliding algorithm should return a valid result
2256        assert!(result.is_ok(), "Sliding method should not error");
2257        let nfp = result.unwrap();
2258        assert!(!nfp.is_empty(), "NFP should not be empty");
2259
2260        // Note: Sliding algorithm is still being improved.
2261        // For simple convex cases, MinkowskiSum is more reliable.
2262        // Sliding is intended for complex non-convex cases with interlocking shapes.
2263    }
2264
2265    #[test]
2266    fn test_nfp_method_config_builder() {
2267        let config = NfpConfig::with_method(NfpMethod::Sliding)
2268            .with_tolerance(1e-5)
2269            .with_max_iterations(5000);
2270
2271        assert_eq!(config.method, NfpMethod::Sliding);
2272        assert!((config.contact_tolerance - 1e-5).abs() < 1e-10);
2273        assert_eq!(config.max_iterations, 5000);
2274    }
2275
2276    #[test]
2277    fn test_nfp_methods_both_succeed() {
2278        let a = Geometry2D::rectangle("A", 10.0, 10.0);
2279        let b = Geometry2D::rectangle("B", 5.0, 5.0);
2280
2281        let nfp_mink = compute_nfp_with_method(&a, &b, 0.0, NfpMethod::MinkowskiSum).unwrap();
2282        let nfp_slide = compute_nfp_with_method(&a, &b, 0.0, NfpMethod::Sliding).unwrap();
2283
2284        // Both methods should produce non-empty results
2285        assert!(!nfp_mink.is_empty());
2286        assert!(!nfp_slide.is_empty());
2287
2288        // Minkowski sum for convex shapes is well-tested
2289        assert!(nfp_mink.vertex_count() >= 4);
2290
2291        // Sliding algorithm produces valid (though possibly different) NFP
2292        // Note: The sliding algorithm implementation is still being refined.
2293        // For simple convex cases, both methods should produce geometrically
2294        // similar results, but vertex count may differ due to different algorithms.
2295    }
2296
2297    #[test]
2298    fn test_nfp_sliding_l_shape() {
2299        // L-shape (non-convex)
2300        let l_shape = Geometry2D::new("L").with_polygon(vec![
2301            (0.0, 0.0),
2302            (20.0, 0.0),
2303            (20.0, 10.0),
2304            (10.0, 10.0),
2305            (10.0, 20.0),
2306            (0.0, 20.0),
2307        ]);
2308
2309        let small_square = Geometry2D::rectangle("S", 5.0, 5.0);
2310
2311        // Sliding should handle non-convex shapes without crashing
2312        let result = compute_nfp_with_method(&l_shape, &small_square, 0.0, NfpMethod::Sliding);
2313
2314        // Sliding algorithm should at least produce some result for L-shapes
2315        assert!(result.is_ok(), "Sliding should not error on L-shape");
2316        let nfp = result.unwrap();
2317        assert!(!nfp.is_empty(), "NFP should not be empty for L-shape");
2318    }
2319
2320    #[test]
2321    fn test_nfp_with_config() {
2322        let a = Geometry2D::rectangle("A", 10.0, 10.0);
2323        let b = Geometry2D::rectangle("B", 5.0, 5.0);
2324
2325        let config = NfpConfig {
2326            method: NfpMethod::Sliding,
2327            contact_tolerance: 1e-4,
2328            max_iterations: 2000,
2329        };
2330
2331        let nfp = compute_nfp_with_config(&a, &b, 0.0, &config).unwrap();
2332
2333        assert!(!nfp.is_empty());
2334    }
2335}