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