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