u_nesting_d2/
nfp_sliding.rs

1//! Improved Sliding Algorithm for No-Fit Polygon generation.
2//!
3//! This module implements the sliding/orbiting algorithm for NFP computation,
4//! based on Burke et al. (2007) "Complete and Robust No-Fit Polygon Generation"
5//! and Luo & Rao (2022) "Improved Sliding Algorithm".
6//!
7//! ## Algorithm Overview
8//!
9//! The sliding algorithm works by:
10//! 1. Placing the orbiting polygon at a valid starting position (touching but not overlapping)
11//! 2. Sliding the orbiting polygon around the stationary polygon while maintaining contact
12//! 3. Recording the path traced by the reference point to form the NFP boundary
13//!
14//! ## Key Concepts
15//!
16//! - **TouchingGroup**: A set of simultaneous contact points between polygons
17//! - **Contact Types**: Vertex-Edge (VE), Edge-Vertex (EV), Edge-Edge (EE)
18//! - **Translation Vector**: The direction along which the orbiting polygon can slide
19//!
20//! ## References
21//!
22//! - [Burke et al. (2007)](https://www.graham-kendall.com/papers/bhkw2007.pdf)
23//! - [Luo & Rao (2022)](https://www.mdpi.com/2227-7390/10/16/2941)
24
25use u_nesting_core::robust::orient2d_filtered;
26use u_nesting_core::{Error, Result};
27
28use crate::nfp::Nfp;
29
30// ============================================================================
31// Contact Types and TouchingGroup
32// ============================================================================
33
34/// Type of contact between two polygons.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ContactType {
37    /// Vertex of orbiting polygon touches edge of stationary polygon.
38    VertexEdge,
39    /// Edge of orbiting polygon touches vertex of stationary polygon.
40    EdgeVertex,
41    /// Edge of orbiting polygon is parallel and touching edge of stationary polygon.
42    EdgeEdge,
43}
44
45/// A single contact point between two polygons.
46#[derive(Debug, Clone)]
47pub struct Contact {
48    /// Type of contact.
49    pub contact_type: ContactType,
50    /// Index of the element (vertex or edge start) on the stationary polygon.
51    pub stationary_idx: usize,
52    /// Index of the element (vertex or edge start) on the orbiting polygon.
53    pub orbiting_idx: usize,
54    /// The contact point in world coordinates.
55    pub point: (f64, f64),
56}
57
58/// A group of simultaneous contacts between two polygons.
59///
60/// When the orbiting polygon is in a certain position, multiple vertices/edges
61/// may be in contact simultaneously. This structure captures all such contacts.
62#[derive(Debug, Clone)]
63pub struct TouchingGroup {
64    /// All contacts in this group.
65    pub contacts: Vec<Contact>,
66    /// The position of the orbiting polygon's reference point.
67    pub reference_position: (f64, f64),
68}
69
70impl TouchingGroup {
71    /// Creates a new empty touching group.
72    pub fn new(reference_position: (f64, f64)) -> Self {
73        Self {
74            contacts: Vec::new(),
75            reference_position,
76        }
77    }
78
79    /// Adds a contact to the group.
80    pub fn add_contact(&mut self, contact: Contact) {
81        self.contacts.push(contact);
82    }
83
84    /// Returns true if this group has any contacts.
85    pub fn has_contacts(&self) -> bool {
86        !self.contacts.is_empty()
87    }
88
89    /// Returns the number of contacts in this group.
90    pub fn contact_count(&self) -> usize {
91        self.contacts.len()
92    }
93}
94
95// ============================================================================
96// Translation Vector
97// ============================================================================
98
99/// A potential translation vector for the orbiting polygon.
100#[derive(Debug, Clone)]
101pub struct TranslationVector {
102    /// Direction of translation (unit vector).
103    pub direction: (f64, f64),
104    /// Maximum distance the polygon can translate in this direction.
105    pub max_distance: f64,
106    /// The contact that generated this translation vector.
107    pub source_contact: Contact,
108}
109
110impl TranslationVector {
111    /// Creates a new translation vector.
112    pub fn new(direction: (f64, f64), max_distance: f64, source_contact: Contact) -> Self {
113        Self {
114            direction,
115            max_distance,
116            source_contact,
117        }
118    }
119
120    /// Returns the actual translation (direction * distance).
121    pub fn translation(&self) -> (f64, f64) {
122        (
123            self.direction.0 * self.max_distance,
124            self.direction.1 * self.max_distance,
125        )
126    }
127}
128
129// ============================================================================
130// Edge and Vertex Utilities
131// ============================================================================
132
133/// Returns the edge vector from vertex i to vertex (i+1) % n.
134#[inline]
135fn edge_vector(polygon: &[(f64, f64)], i: usize) -> (f64, f64) {
136    let n = polygon.len();
137    let p1 = polygon[i];
138    let p2 = polygon[(i + 1) % n];
139    (p2.0 - p1.0, p2.1 - p1.1)
140}
141
142/// Returns the outward normal of an edge (assuming CCW polygon).
143#[inline]
144#[allow(dead_code)]
145fn edge_normal(polygon: &[(f64, f64)], i: usize) -> (f64, f64) {
146    let (dx, dy) = edge_vector(polygon, i);
147    let len = (dx * dx + dy * dy).sqrt();
148    if len < 1e-10 {
149        (0.0, 0.0)
150    } else {
151        // Rotate 90 degrees clockwise for outward normal (CCW polygon)
152        (dy / len, -dx / len)
153    }
154}
155
156/// Normalizes a vector to unit length.
157#[inline]
158fn normalize(v: (f64, f64)) -> (f64, f64) {
159    let len = (v.0 * v.0 + v.1 * v.1).sqrt();
160    if len < 1e-10 {
161        (0.0, 0.0)
162    } else {
163        (v.0 / len, v.1 / len)
164    }
165}
166
167/// Computes the dot product of two vectors.
168#[inline]
169fn dot(a: (f64, f64), b: (f64, f64)) -> f64 {
170    a.0 * b.0 + a.1 * b.1
171}
172
173/// Computes the cross product (z-component) of two 2D vectors.
174#[inline]
175fn cross(a: (f64, f64), b: (f64, f64)) -> f64 {
176    a.0 * b.1 - a.1 * b.0
177}
178
179/// Distance between two points.
180#[inline]
181fn distance(a: (f64, f64), b: (f64, f64)) -> f64 {
182    let dx = b.0 - a.0;
183    let dy = b.1 - a.1;
184    (dx * dx + dy * dy).sqrt()
185}
186
187/// Checks if two edges are parallel (within tolerance).
188#[inline]
189fn edges_parallel(e1: (f64, f64), e2: (f64, f64)) -> bool {
190    let c = cross(e1, e2).abs();
191    let len1 = (e1.0 * e1.0 + e1.1 * e1.1).sqrt();
192    let len2 = (e2.0 * e2.0 + e2.1 * e2.1).sqrt();
193    c < 1e-10 * len1 * len2
194}
195
196/// Projects a point onto a line segment and returns the parameter t.
197/// t=0 means at p1, t=1 means at p2.
198fn project_point_to_segment(point: (f64, f64), p1: (f64, f64), p2: (f64, f64)) -> f64 {
199    let dx = p2.0 - p1.0;
200    let dy = p2.1 - p1.1;
201    let len_sq = dx * dx + dy * dy;
202    if len_sq < 1e-20 {
203        return 0.0;
204    }
205    let t = ((point.0 - p1.0) * dx + (point.1 - p1.1) * dy) / len_sq;
206    t.clamp(0.0, 1.0)
207}
208
209/// Distance from a point to a line segment.
210fn point_to_segment_distance(point: (f64, f64), p1: (f64, f64), p2: (f64, f64)) -> f64 {
211    let t = project_point_to_segment(point, p1, p2);
212    let proj = (p1.0 + t * (p2.0 - p1.0), p1.1 + t * (p2.1 - p1.1));
213    distance(point, proj)
214}
215
216/// Checks if a point lies on a segment (within tolerance).
217fn point_on_segment(point: (f64, f64), p1: (f64, f64), p2: (f64, f64), tol: f64) -> bool {
218    point_to_segment_distance(point, p1, p2) < tol
219}
220
221// ============================================================================
222// Contact Detection
223// ============================================================================
224
225/// Finds all contacts between a stationary polygon and an orbiting polygon
226/// at its current position.
227///
228/// # Arguments
229/// * `stationary` - The fixed polygon (CCW)
230/// * `orbiting` - The orbiting polygon (CCW) at its current translated position
231/// * `tolerance` - Distance tolerance for contact detection
232pub fn find_contacts(
233    stationary: &[(f64, f64)],
234    orbiting: &[(f64, f64)],
235    tolerance: f64,
236) -> Vec<Contact> {
237    let mut contacts = Vec::new();
238
239    let n_stat = stationary.len();
240    let n_orb = orbiting.len();
241
242    // Check vertex-edge contacts (orbiting vertex on stationary edge)
243    for (orb_idx, &orb_vertex) in orbiting.iter().enumerate() {
244        for stat_edge_idx in 0..n_stat {
245            let stat_p1 = stationary[stat_edge_idx];
246            let stat_p2 = stationary[(stat_edge_idx + 1) % n_stat];
247            if point_on_segment(orb_vertex, stat_p1, stat_p2, tolerance) {
248                contacts.push(Contact {
249                    contact_type: ContactType::VertexEdge,
250                    stationary_idx: stat_edge_idx,
251                    orbiting_idx: orb_idx,
252                    point: orb_vertex,
253                });
254            }
255        }
256    }
257
258    // Check edge-vertex contacts (stationary vertex on orbiting edge)
259    for (stat_idx, &stat_vertex) in stationary.iter().enumerate() {
260        for orb_edge_idx in 0..n_orb {
261            let orb_p1 = orbiting[orb_edge_idx];
262            let orb_p2 = orbiting[(orb_edge_idx + 1) % n_orb];
263            if point_on_segment(stat_vertex, orb_p1, orb_p2, tolerance) {
264                contacts.push(Contact {
265                    contact_type: ContactType::EdgeVertex,
266                    stationary_idx: stat_idx,
267                    orbiting_idx: orb_edge_idx,
268                    point: stat_vertex,
269                });
270            }
271        }
272    }
273
274    // Check edge-edge contacts (parallel overlapping edges)
275    for stat_edge_idx in 0..n_stat {
276        let stat_p1 = stationary[stat_edge_idx];
277        let stat_p2 = stationary[(stat_edge_idx + 1) % n_stat];
278        let stat_edge = (stat_p2.0 - stat_p1.0, stat_p2.1 - stat_p1.1);
279
280        for orb_edge_idx in 0..n_orb {
281            let orb_p1 = orbiting[orb_edge_idx];
282            let orb_p2 = orbiting[(orb_edge_idx + 1) % n_orb];
283            let orb_edge = (orb_p2.0 - orb_p1.0, orb_p2.1 - orb_p1.1);
284
285            // Check if edges are parallel
286            if edges_parallel(stat_edge, orb_edge) {
287                // Check if edges overlap (both endpoints of one edge are on the other's line)
288                let d1 = point_to_segment_distance(orb_p1, stat_p1, stat_p2);
289                let d2 = point_to_segment_distance(orb_p2, stat_p1, stat_p2);
290                if d1 < tolerance && d2 < tolerance {
291                    // Edges are collinear and potentially overlapping
292                    // Use midpoint of overlap as contact point
293                    let mid = ((orb_p1.0 + orb_p2.0) / 2.0, (orb_p1.1 + orb_p2.1) / 2.0);
294                    contacts.push(Contact {
295                        contact_type: ContactType::EdgeEdge,
296                        stationary_idx: stat_edge_idx,
297                        orbiting_idx: orb_edge_idx,
298                        point: mid,
299                    });
300                }
301            }
302        }
303    }
304
305    contacts
306}
307
308/// Creates a touching group from the current contacts.
309pub fn create_touching_group(
310    stationary: &[(f64, f64)],
311    orbiting: &[(f64, f64)],
312    reference_position: (f64, f64),
313    tolerance: f64,
314) -> TouchingGroup {
315    let contacts = find_contacts(stationary, orbiting, tolerance);
316    let mut group = TouchingGroup::new(reference_position);
317    for contact in contacts {
318        group.add_contact(contact);
319    }
320    group
321}
322
323// ============================================================================
324// Translation Vector Computation
325// ============================================================================
326
327/// Computes potential translation vectors from a touching group.
328///
329/// Each contact generates one or two potential translation vectors
330/// (sliding directions along the contact).
331pub fn compute_translation_vectors(
332    touching_group: &TouchingGroup,
333    stationary: &[(f64, f64)],
334    orbiting: &[(f64, f64)],
335) -> Vec<TranslationVector> {
336    let mut vectors = Vec::new();
337
338    for contact in &touching_group.contacts {
339        match contact.contact_type {
340            ContactType::VertexEdge => {
341                // Orbiting vertex on stationary edge
342                // Can slide along the stationary edge direction
343                let edge = edge_vector(stationary, contact.stationary_idx);
344                let dir = normalize(edge);
345                let neg_dir = (-dir.0, -dir.1);
346
347                // Calculate max distance to edge endpoint
348                let n = stationary.len();
349                let edge_end = stationary[(contact.stationary_idx + 1) % n];
350                let edge_start = stationary[contact.stationary_idx];
351                let max_dist_pos = distance(contact.point, edge_end);
352                let max_dist_neg = distance(contact.point, edge_start);
353
354                vectors.push(TranslationVector::new(dir, max_dist_pos, contact.clone()));
355                vectors.push(TranslationVector::new(
356                    neg_dir,
357                    max_dist_neg,
358                    contact.clone(),
359                ));
360            }
361            ContactType::EdgeVertex => {
362                // Stationary vertex on orbiting edge
363                // The orbiting polygon can slide along the orbiting edge direction
364                let edge = edge_vector(orbiting, contact.orbiting_idx);
365                let dir = normalize(edge);
366                let neg_dir = (-dir.0, -dir.1);
367
368                // For EV contact, max distance is to orbiting edge endpoints
369                let n = orbiting.len();
370                let orb_edge_end = orbiting[(contact.orbiting_idx + 1) % n];
371                let orb_edge_start = orbiting[contact.orbiting_idx];
372
373                // The orbiting polygon needs to move so that the stationary vertex
374                // stays on the orbiting edge - this is opposite direction
375                let dist_to_end = distance(contact.point, orb_edge_end);
376                let dist_to_start = distance(contact.point, orb_edge_start);
377
378                vectors.push(TranslationVector::new(
379                    neg_dir,
380                    dist_to_end,
381                    contact.clone(),
382                ));
383                vectors.push(TranslationVector::new(dir, dist_to_start, contact.clone()));
384            }
385            ContactType::EdgeEdge => {
386                // Parallel edges in contact
387                // Can slide along the edge direction
388                let edge = edge_vector(stationary, contact.stationary_idx);
389                let dir = normalize(edge);
390                let neg_dir = (-dir.0, -dir.1);
391
392                // Approximate max distance based on edge lengths
393                let stat_edge_len = {
394                    let n = stationary.len();
395                    distance(
396                        stationary[contact.stationary_idx],
397                        stationary[(contact.stationary_idx + 1) % n],
398                    )
399                };
400                let orb_edge_len = {
401                    let n = orbiting.len();
402                    distance(
403                        orbiting[contact.orbiting_idx],
404                        orbiting[(contact.orbiting_idx + 1) % n],
405                    )
406                };
407                let max_dist = stat_edge_len + orb_edge_len;
408
409                vectors.push(TranslationVector::new(dir, max_dist, contact.clone()));
410                vectors.push(TranslationVector::new(neg_dir, max_dist, contact.clone()));
411            }
412        }
413    }
414
415    vectors
416}
417
418/// Selects the best translation vector for NFP generation.
419///
420/// The algorithm prefers counter-clockwise traversal around the stationary polygon.
421/// This ensures consistent NFP boundary generation.
422pub fn select_translation_vector(
423    vectors: &[TranslationVector],
424    previous_direction: Option<(f64, f64)>,
425    stationary_centroid: (f64, f64),
426    orbiting_position: (f64, f64),
427) -> Option<&TranslationVector> {
428    if vectors.is_empty() {
429        return None;
430    }
431
432    // Calculate the direction from stationary centroid to orbiting position
433    let radial = normalize((
434        orbiting_position.0 - stationary_centroid.0,
435        orbiting_position.1 - stationary_centroid.1,
436    ));
437
438    // Prefer CCW direction: perpendicular to radial, rotated 90° CCW
439    let ccw_preferred = (-radial.1, radial.0);
440
441    // Score each vector based on CCW preference and continuity
442    let mut best_idx = 0;
443    let mut best_score = f64::NEG_INFINITY;
444
445    for (i, v) in vectors.iter().enumerate() {
446        let mut score = dot(v.direction, ccw_preferred);
447
448        // Bonus for continuity with previous direction
449        if let Some(prev) = previous_direction {
450            score += 0.5 * dot(v.direction, prev);
451        }
452
453        // Penalize very short translations
454        if v.max_distance < 1e-6 {
455            score -= 100.0;
456        }
457
458        if score > best_score {
459            best_score = score;
460            best_idx = i;
461        }
462    }
463
464    Some(&vectors[best_idx])
465}
466
467// ============================================================================
468// Edge Case Handling
469// ============================================================================
470
471/// Represents a potential collision during translation.
472#[derive(Debug, Clone)]
473pub struct CollisionEvent {
474    /// Distance along translation at which collision occurs.
475    pub distance: f64,
476    /// Type of contact formed at collision.
477    pub contact_type: ContactType,
478    /// Index on stationary polygon.
479    pub stationary_idx: usize,
480    /// Index on orbiting polygon.
481    pub orbiting_idx: usize,
482}
483
484/// Checks for potential collisions during a translation.
485///
486/// This handles the "blocking" case where the orbiting polygon would
487/// collide with another part of the stationary polygon before reaching
488/// the full translation distance.
489///
490/// # Returns
491/// The first collision event if one occurs, or None if the translation is clear.
492pub fn check_translation_collision(
493    stationary: &[(f64, f64)],
494    orbiting: &[(f64, f64)],
495    translation: (f64, f64),
496    tolerance: f64,
497) -> Option<CollisionEvent> {
498    let trans_len = (translation.0 * translation.0 + translation.1 * translation.1).sqrt();
499    if trans_len < tolerance {
500        return None;
501    }
502    let trans_dir = (translation.0 / trans_len, translation.1 / trans_len);
503
504    let n_stat = stationary.len();
505    let n_orb = orbiting.len();
506
507    let mut first_collision: Option<CollisionEvent> = None;
508
509    // Check orbiting vertices against stationary edges
510    for (orb_idx, &orb_v) in orbiting.iter().enumerate() {
511        for stat_edge_idx in 0..n_stat {
512            let stat_p1 = stationary[stat_edge_idx];
513            let stat_p2 = stationary[(stat_edge_idx + 1) % n_stat];
514
515            if let Some(dist) =
516                ray_segment_intersection(orb_v, trans_dir, stat_p1, stat_p2, tolerance)
517            {
518                if dist > tolerance
519                    && dist < trans_len - tolerance
520                    && (first_collision.is_none()
521                        || dist < first_collision.as_ref().unwrap().distance)
522                {
523                    first_collision = Some(CollisionEvent {
524                        distance: dist,
525                        contact_type: ContactType::VertexEdge,
526                        stationary_idx: stat_edge_idx,
527                        orbiting_idx: orb_idx,
528                    });
529                }
530            }
531        }
532    }
533
534    // Check stationary vertices against moving orbiting edges
535    for (stat_idx, &stat_v) in stationary.iter().enumerate() {
536        for orb_edge_idx in 0..n_orb {
537            let orb_p1 = orbiting[orb_edge_idx];
538            let orb_p2 = orbiting[(orb_edge_idx + 1) % n_orb];
539
540            // The stationary vertex appears to move in the opposite direction
541            let neg_dir = (-trans_dir.0, -trans_dir.1);
542
543            if let Some(dist) = ray_segment_intersection(stat_v, neg_dir, orb_p1, orb_p2, tolerance)
544            {
545                if dist > tolerance
546                    && dist < trans_len - tolerance
547                    && (first_collision.is_none()
548                        || dist < first_collision.as_ref().unwrap().distance)
549                {
550                    first_collision = Some(CollisionEvent {
551                        distance: dist,
552                        contact_type: ContactType::EdgeVertex,
553                        stationary_idx: stat_idx,
554                        orbiting_idx: orb_edge_idx,
555                    });
556                }
557            }
558        }
559    }
560
561    first_collision
562}
563
564/// Computes the intersection of a ray with a line segment.
565///
566/// # Returns
567/// The distance along the ray to the intersection point, or None if no intersection.
568fn ray_segment_intersection(
569    ray_origin: (f64, f64),
570    ray_dir: (f64, f64),
571    seg_p1: (f64, f64),
572    seg_p2: (f64, f64),
573    tolerance: f64,
574) -> Option<f64> {
575    let seg_dir = (seg_p2.0 - seg_p1.0, seg_p2.1 - seg_p1.1);
576    let denominator = cross(ray_dir, seg_dir);
577
578    // Check if ray and segment are parallel
579    if denominator.abs() < tolerance {
580        return None;
581    }
582
583    // Vector from segment start to ray origin
584    let diff = (seg_p1.0 - ray_origin.0, seg_p1.1 - ray_origin.1);
585
586    // Parameter along ray: t
587    // Parameter along segment: u
588    // Ray equation: ray_origin + t * ray_dir
589    // Segment equation: seg_p1 + u * seg_dir
590    // Solve: ray_origin + t * ray_dir = seg_p1 + u * seg_dir
591
592    let t = cross(diff, seg_dir) / denominator;
593    let u = cross(diff, ray_dir) / denominator;
594
595    // Check if intersection is in valid range
596    // t >= 0 means intersection is in front of ray
597    // 0 <= u <= 1 means intersection is on segment
598    if t >= -tolerance && u >= -tolerance && u <= 1.0 + tolerance {
599        Some(t.max(0.0))
600    } else {
601        None
602    }
603}
604
605/// Handles the "perfect fit" edge case where polygons fit together exactly.
606///
607/// In this case, multiple translation vectors may have the same score,
608/// and we need to carefully choose the one that continues the CCW traversal.
609pub fn handle_perfect_fit(
610    touching_group: &TouchingGroup,
611    stationary: &[(f64, f64)],
612    orbiting: &[(f64, f64)],
613    visited_positions: &[(f64, f64)],
614    tolerance: f64,
615) -> Vec<TranslationVector> {
616    let mut vectors = compute_translation_vectors(touching_group, stationary, orbiting);
617
618    // Filter out vectors that would lead to already-visited positions
619    vectors.retain(|v| {
620        let potential_pos = (
621            touching_group.reference_position.0 + v.direction.0 * v.max_distance.min(1.0),
622            touching_group.reference_position.1 + v.direction.1 * v.max_distance.min(1.0),
623        );
624
625        !visited_positions
626            .iter()
627            .any(|&visited| distance(potential_pos, visited) < tolerance * 10.0)
628    });
629
630    vectors
631}
632
633/// Handles interlocking concavities where the orbiting polygon may need to
634/// enter a concave region of the stationary polygon.
635///
636/// This generates additional NFP vertices where the reference point can
637/// reach into concave regions.
638pub fn detect_interlocking_opportunity(
639    stationary: &[(f64, f64)],
640    orbiting: &[(f64, f64)],
641    current_pos: (f64, f64),
642    tolerance: f64,
643) -> Option<(f64, f64)> {
644    // Find concave vertices of stationary polygon
645    let n_stat = stationary.len();
646    let mut concave_vertices = Vec::new();
647
648    for i in 0..n_stat {
649        let prev = stationary[if i == 0 { n_stat - 1 } else { i - 1 }];
650        let curr = stationary[i];
651        let next = stationary[(i + 1) % n_stat];
652
653        // Check if this vertex is concave (reflex)
654        let orientation = orient2d_filtered(prev, curr, next);
655        if orientation.is_cw() {
656            concave_vertices.push((i, curr));
657        }
658    }
659
660    if concave_vertices.is_empty() {
661        return None;
662    }
663
664    // Check if orbiting polygon can fit into any concave region
665    let orb_bbox = compute_bbox(orbiting);
666    let orb_width = orb_bbox.1 .0 - orb_bbox.0 .0;
667    let orb_height = orb_bbox.1 .1 - orb_bbox.0 .1;
668
669    for (idx, vertex) in concave_vertices {
670        // Compute the "pocket" size at this concave vertex
671        let prev_idx = if idx == 0 { n_stat - 1 } else { idx - 1 };
672        let _next_idx = (idx + 1) % n_stat;
673
674        let prev_edge = edge_vector(stationary, prev_idx);
675        let next_edge = edge_vector(stationary, idx);
676
677        // Estimate pocket opening
678        let opening_width = (dot(prev_edge, next_edge).abs()).sqrt();
679
680        // Check if orbiting polygon might fit
681        let min_dim = orb_width.min(orb_height);
682        if opening_width > min_dim * 0.5 {
683            // This concave region might be accessible
684            // Compute a potential position toward this vertex
685            let dir = normalize((vertex.0 - current_pos.0, vertex.1 - current_pos.1));
686            let test_pos = (
687                current_pos.0 + dir.0 * tolerance,
688                current_pos.1 + dir.1 * tolerance,
689            );
690
691            // Verify this doesn't cause overlap
692            let orbiting_test: Vec<(f64, f64)> = orbiting
693                .iter()
694                .map(|&(x, y)| (x + test_pos.0, y + test_pos.1))
695                .collect();
696
697            if !polygons_overlap(stationary, &orbiting_test) {
698                return Some(test_pos);
699            }
700        }
701    }
702
703    None
704}
705
706/// Computes the axis-aligned bounding box of a polygon.
707fn compute_bbox(polygon: &[(f64, f64)]) -> ((f64, f64), (f64, f64)) {
708    let min_x = polygon
709        .iter()
710        .map(|p| p.0)
711        .fold(f64::INFINITY, |a, b| a.min(b));
712    let max_x = polygon
713        .iter()
714        .map(|p| p.0)
715        .fold(f64::NEG_INFINITY, |a, b| a.max(b));
716    let min_y = polygon
717        .iter()
718        .map(|p| p.1)
719        .fold(f64::INFINITY, |a, b| a.min(b));
720    let max_y = polygon
721        .iter()
722        .map(|p| p.1)
723        .fold(f64::NEG_INFINITY, |a, b| a.max(b));
724    ((min_x, min_y), (max_x, max_y))
725}
726
727/// Checks if two polygons overlap using separating axis theorem.
728fn polygons_overlap(poly_a: &[(f64, f64)], poly_b: &[(f64, f64)]) -> bool {
729    // Quick AABB check first
730    let bbox_a = compute_bbox(poly_a);
731    let bbox_b = compute_bbox(poly_b);
732
733    if bbox_a.1 .0 < bbox_b.0 .0
734        || bbox_b.1 .0 < bbox_a.0 .0
735        || bbox_a.1 .1 < bbox_b.0 .1
736        || bbox_b.1 .1 < bbox_a.0 .1
737    {
738        return false;
739    }
740
741    // SAT check with edges from both polygons
742    for polygon in [poly_a, poly_b] {
743        let n = polygon.len();
744        for i in 0..n {
745            let edge = edge_vector(polygon, i);
746            let axis = normalize((-edge.1, edge.0)); // Normal to edge
747
748            if axis.0.abs() < 1e-10 && axis.1.abs() < 1e-10 {
749                continue;
750            }
751
752            // Project both polygons onto axis
753            let (min_a, max_a) = project_polygon_on_axis(poly_a, axis);
754            let (min_b, max_b) = project_polygon_on_axis(poly_b, axis);
755
756            // Check for gap
757            if max_a < min_b || max_b < min_a {
758                return false; // Separating axis found
759            }
760        }
761    }
762
763    true // No separating axis found, polygons overlap
764}
765
766/// Projects a polygon onto an axis and returns (min, max) extent.
767fn project_polygon_on_axis(polygon: &[(f64, f64)], axis: (f64, f64)) -> (f64, f64) {
768    let mut min_proj = f64::INFINITY;
769    let mut max_proj = f64::NEG_INFINITY;
770
771    for &p in polygon {
772        let proj = dot(p, axis);
773        min_proj = min_proj.min(proj);
774        max_proj = max_proj.max(proj);
775    }
776
777    (min_proj, max_proj)
778}
779
780// ============================================================================
781// Sliding NFP Algorithm
782// ============================================================================
783
784/// Configuration for the sliding NFP algorithm.
785#[derive(Debug, Clone)]
786pub struct SlidingNfpConfig {
787    /// Tolerance for contact detection.
788    pub contact_tolerance: f64,
789    /// Maximum number of iterations to prevent infinite loops.
790    pub max_iterations: usize,
791    /// Minimum translation distance before stopping.
792    pub min_translation: f64,
793}
794
795impl Default for SlidingNfpConfig {
796    fn default() -> Self {
797        Self {
798            contact_tolerance: 1e-6,
799            max_iterations: 10000,
800            min_translation: 1e-8,
801        }
802    }
803}
804
805/// Computes the NFP using the sliding algorithm.
806///
807/// # Arguments
808/// * `stationary` - The fixed polygon (CCW winding)
809/// * `orbiting` - The orbiting polygon (CCW winding)
810/// * `config` - Algorithm configuration
811///
812/// # Returns
813/// The computed NFP as a list of polygons (outer boundary + potential holes)
814pub fn compute_nfp_sliding(
815    stationary: &[(f64, f64)],
816    orbiting: &[(f64, f64)],
817    config: &SlidingNfpConfig,
818) -> Result<Nfp> {
819    if stationary.len() < 3 || orbiting.len() < 3 {
820        return Err(Error::InvalidGeometry(
821            "Polygons must have at least 3 vertices".into(),
822        ));
823    }
824
825    // Ensure CCW winding
826    let stationary = ensure_ccw(stationary);
827    let orbiting = ensure_ccw(orbiting);
828
829    // Step 1: Find a valid starting position
830    let start_pos = find_start_position(&stationary, &orbiting)?;
831
832    // Step 2: Translate orbiting to start position
833    let orbiting_at_start: Vec<(f64, f64)> = orbiting
834        .iter()
835        .map(|&(x, y)| (x + start_pos.0, y + start_pos.1))
836        .collect();
837
838    // Step 3: Trace the NFP boundary by sliding
839    let nfp_path = trace_nfp_boundary(
840        &stationary,
841        &orbiting,
842        &orbiting_at_start,
843        start_pos,
844        config,
845    )?;
846
847    if nfp_path.len() < 3 {
848        return Err(Error::Internal("NFP path has fewer than 3 vertices".into()));
849    }
850
851    Ok(Nfp::from_polygon(nfp_path))
852}
853
854/// Finds a valid starting position for the orbiting polygon.
855///
856/// The start position is where the orbiting polygon touches the stationary
857/// polygon from the "bottom-left" direction (minimizing y, then x).
858fn find_start_position(stationary: &[(f64, f64)], orbiting: &[(f64, f64)]) -> Result<(f64, f64)> {
859    // Find the bottom-most vertex of stationary polygon
860    let stat_bottom_idx = stationary
861        .iter()
862        .enumerate()
863        .min_by(|(_, a), (_, b)| {
864            a.1.partial_cmp(&b.1)
865                .unwrap()
866                .then(a.0.partial_cmp(&b.0).unwrap())
867        })
868        .map(|(i, _)| i)
869        .unwrap_or(0);
870    let stat_bottom = stationary[stat_bottom_idx];
871
872    // Find the top-most vertex of orbiting polygon
873    let orb_top_idx = orbiting
874        .iter()
875        .enumerate()
876        .max_by(|(_, a), (_, b)| {
877            a.1.partial_cmp(&b.1)
878                .unwrap()
879                .then(b.0.partial_cmp(&a.0).unwrap())
880        })
881        .map(|(i, _)| i)
882        .unwrap_or(0);
883    let orb_top = orbiting[orb_top_idx];
884
885    // Position orbiting so its top vertex touches stationary's bottom vertex
886    // The reference point of orbiting is at origin (0, 0)
887    let start_pos = (stat_bottom.0 - orb_top.0, stat_bottom.1 - orb_top.1);
888
889    Ok(start_pos)
890}
891
892/// Traces the NFP boundary by sliding the orbiting polygon around the stationary polygon.
893fn trace_nfp_boundary(
894    stationary: &[(f64, f64)],
895    orbiting_original: &[(f64, f64)],
896    _orbiting_at_start: &[(f64, f64)],
897    start_pos: (f64, f64),
898    config: &SlidingNfpConfig,
899) -> Result<Vec<(f64, f64)>> {
900    let mut nfp_path = Vec::new();
901    let mut current_pos = start_pos;
902    let mut previous_direction: Option<(f64, f64)> = None;
903    let mut stuck_counter = 0;
904
905    // Calculate stationary centroid for CCW preference
906    let stat_centroid = polygon_centroid(stationary);
907
908    nfp_path.push(current_pos);
909
910    for _iteration in 0..config.max_iterations {
911        // Translate orbiting polygon to current position
912        let orbiting_current: Vec<(f64, f64)> = orbiting_original
913            .iter()
914            .map(|&(x, y)| (x + current_pos.0, y + current_pos.1))
915            .collect();
916
917        // Find contacts at current position
918        let touching_group = create_touching_group(
919            stationary,
920            &orbiting_current,
921            current_pos,
922            config.contact_tolerance,
923        );
924
925        if !touching_group.has_contacts() {
926            // No contacts - try to recover by moving toward stationary polygon
927            if let Some(recovery_pos) = recover_contact(
928                stationary,
929                &orbiting_current,
930                current_pos,
931                config.contact_tolerance,
932            ) {
933                current_pos = recovery_pos;
934                continue;
935            }
936            break;
937        }
938
939        // Handle perfect fit case - filter vectors that lead to visited positions
940        let translation_vectors = handle_perfect_fit(
941            &touching_group,
942            stationary,
943            &orbiting_current,
944            &nfp_path,
945            config.contact_tolerance,
946        );
947
948        if translation_vectors.is_empty() {
949            // All directions lead to visited positions - might be done or stuck
950            stuck_counter += 1;
951            if stuck_counter > 3 {
952                break;
953            }
954            // Try to find interlocking opportunity
955            if let Some(interlock_pos) = detect_interlocking_opportunity(
956                stationary,
957                orbiting_original,
958                current_pos,
959                config.contact_tolerance,
960            ) {
961                if distance(interlock_pos, current_pos) > config.min_translation {
962                    nfp_path.push(interlock_pos);
963                    current_pos = interlock_pos;
964                    continue;
965                }
966            }
967            break;
968        }
969        stuck_counter = 0;
970
971        // Select the best translation vector
972        let selected = select_translation_vector(
973            &translation_vectors,
974            previous_direction,
975            stat_centroid,
976            current_pos,
977        );
978
979        let Some(tv) = selected else {
980            break;
981        };
982
983        // Calculate intended translation
984        let intended_distance = tv.max_distance.min(1000.0); // Cap for safety
985        if intended_distance < config.min_translation {
986            break;
987        }
988
989        let intended_translation = (
990            tv.direction.0 * intended_distance,
991            tv.direction.1 * intended_distance,
992        );
993
994        // Check for collisions during translation
995        let actual_translation = if let Some(collision) = check_translation_collision(
996            stationary,
997            &orbiting_current,
998            intended_translation,
999            config.contact_tolerance,
1000        ) {
1001            // Stop at the collision point
1002            let blocked_dist = (collision.distance - config.contact_tolerance).max(0.0);
1003            (tv.direction.0 * blocked_dist, tv.direction.1 * blocked_dist)
1004        } else {
1005            intended_translation
1006        };
1007
1008        let new_pos = (
1009            current_pos.0 + actual_translation.0,
1010            current_pos.1 + actual_translation.1,
1011        );
1012
1013        // Check if we've returned to start
1014        let dist_to_start = distance(new_pos, start_pos);
1015        if nfp_path.len() > 2 && dist_to_start < config.contact_tolerance * 10.0 {
1016            // Completed the loop
1017            break;
1018        }
1019
1020        // Check if this position is distinct from the last
1021        let dist_to_last = distance(new_pos, current_pos);
1022        if dist_to_last > config.min_translation {
1023            nfp_path.push(new_pos);
1024            previous_direction = Some(tv.direction);
1025        }
1026
1027        current_pos = new_pos;
1028    }
1029
1030    // Simplify the path by removing collinear points
1031    simplify_polygon(&nfp_path, config.contact_tolerance)
1032}
1033
1034/// Attempts to recover contact when the orbiting polygon loses contact with stationary.
1035fn recover_contact(
1036    stationary: &[(f64, f64)],
1037    orbiting: &[(f64, f64)],
1038    current_pos: (f64, f64),
1039    tolerance: f64,
1040) -> Option<(f64, f64)> {
1041    // Find the closest point on stationary boundary to any orbiting vertex
1042    let mut min_dist = f64::INFINITY;
1043    let mut closest_point = None;
1044
1045    let n_stat = stationary.len();
1046
1047    for orb_v in orbiting {
1048        for i in 0..n_stat {
1049            let stat_p1 = stationary[i];
1050            let stat_p2 = stationary[(i + 1) % n_stat];
1051
1052            let t = project_point_to_segment(*orb_v, stat_p1, stat_p2);
1053            let proj = (
1054                stat_p1.0 + t * (stat_p2.0 - stat_p1.0),
1055                stat_p1.1 + t * (stat_p2.1 - stat_p1.1),
1056            );
1057
1058            let dist = distance(*orb_v, proj);
1059            if dist < min_dist {
1060                min_dist = dist;
1061                closest_point = Some(proj);
1062            }
1063        }
1064    }
1065
1066    // Move toward the closest point
1067    if let Some(target) = closest_point {
1068        if min_dist > tolerance {
1069            // Calculate direction to move
1070            let orb_centroid = polygon_centroid(orbiting);
1071            let dir = normalize((target.0 - orb_centroid.0, target.1 - orb_centroid.1));
1072            let move_dist = min_dist - tolerance * 0.5;
1073            return Some((
1074                current_pos.0 + dir.0 * move_dist,
1075                current_pos.1 + dir.1 * move_dist,
1076            ));
1077        }
1078    }
1079
1080    None
1081}
1082
1083/// Computes polygon centroid.
1084fn polygon_centroid(polygon: &[(f64, f64)]) -> (f64, f64) {
1085    let n = polygon.len() as f64;
1086    let sum_x: f64 = polygon.iter().map(|p| p.0).sum();
1087    let sum_y: f64 = polygon.iter().map(|p| p.1).sum();
1088    (sum_x / n, sum_y / n)
1089}
1090
1091/// Ensures polygon is in counter-clockwise order.
1092fn ensure_ccw(polygon: &[(f64, f64)]) -> Vec<(f64, f64)> {
1093    let area = signed_area(polygon);
1094    if area < 0.0 {
1095        polygon.iter().rev().cloned().collect()
1096    } else {
1097        polygon.to_vec()
1098    }
1099}
1100
1101/// Computes signed area of a polygon.
1102fn signed_area(polygon: &[(f64, f64)]) -> f64 {
1103    let n = polygon.len();
1104    let mut area = 0.0;
1105    for i in 0..n {
1106        let j = (i + 1) % n;
1107        area += polygon[i].0 * polygon[j].1;
1108        area -= polygon[j].0 * polygon[i].1;
1109    }
1110    area / 2.0
1111}
1112
1113/// Simplifies a polygon by removing collinear points.
1114fn simplify_polygon(polygon: &[(f64, f64)], tolerance: f64) -> Result<Vec<(f64, f64)>> {
1115    if polygon.len() < 3 {
1116        return Ok(polygon.to_vec());
1117    }
1118
1119    let mut result = Vec::new();
1120    let n = polygon.len();
1121
1122    for i in 0..n {
1123        let prev = if i == 0 { n - 1 } else { i - 1 };
1124        let next = (i + 1) % n;
1125
1126        let p_prev = polygon[prev];
1127        let p_curr = polygon[i];
1128        let p_next = polygon[next];
1129
1130        // Check if current point is collinear with neighbors
1131        let orientation = orient2d_filtered(p_prev, p_curr, p_next);
1132        if !orientation.is_collinear() {
1133            result.push(p_curr);
1134        } else {
1135            // Also keep if distance from line is significant
1136            let dist = point_to_segment_distance(p_curr, p_prev, p_next);
1137            if dist > tolerance {
1138                result.push(p_curr);
1139            }
1140        }
1141    }
1142
1143    if result.len() < 3 {
1144        // Can't simplify further, return original
1145        Ok(polygon.to_vec())
1146    } else {
1147        Ok(result)
1148    }
1149}
1150
1151// ============================================================================
1152// Tests
1153// ============================================================================
1154
1155#[cfg(test)]
1156mod tests {
1157    use super::*;
1158
1159    fn rect(w: f64, h: f64) -> Vec<(f64, f64)> {
1160        vec![(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)]
1161    }
1162
1163    #[test]
1164    fn test_edge_vector() {
1165        let square = rect(10.0, 10.0);
1166        let e0 = edge_vector(&square, 0);
1167        assert!((e0.0 - 10.0).abs() < 1e-10);
1168        assert!(e0.1.abs() < 1e-10);
1169    }
1170
1171    #[test]
1172    fn test_edge_normal() {
1173        let square = rect(10.0, 10.0);
1174        // Bottom edge normal should point down (outside CCW polygon)
1175        let n0 = edge_normal(&square, 0);
1176        assert!(n0.0.abs() < 1e-10);
1177        assert!((n0.1 - (-1.0)).abs() < 1e-10);
1178    }
1179
1180    #[test]
1181    fn test_edges_parallel() {
1182        assert!(edges_parallel((1.0, 0.0), (2.0, 0.0)));
1183        assert!(edges_parallel((1.0, 0.0), (-1.0, 0.0)));
1184        assert!(!edges_parallel((1.0, 0.0), (0.0, 1.0)));
1185    }
1186
1187    #[test]
1188    fn test_point_on_segment() {
1189        let p1 = (0.0, 0.0);
1190        let p2 = (10.0, 0.0);
1191
1192        assert!(point_on_segment((5.0, 0.0), p1, p2, 1e-6));
1193        assert!(point_on_segment((0.0, 0.0), p1, p2, 1e-6));
1194        assert!(point_on_segment((10.0, 0.0), p1, p2, 1e-6));
1195        assert!(!point_on_segment((5.0, 1.0), p1, p2, 1e-6));
1196    }
1197
1198    #[test]
1199    fn test_find_contacts_ve() {
1200        // Two squares: orbiting square's corner touching stationary's edge
1201        let stationary = rect(10.0, 10.0);
1202        let orbiting: Vec<(f64, f64)> = rect(5.0, 5.0)
1203            .iter()
1204            .map(|&(x, y)| (x + 5.0, y + 10.0)) // Place above, touching top edge
1205            .collect();
1206
1207        let contacts = find_contacts(&stationary, &orbiting, 1e-6);
1208
1209        // Should find vertex-edge contact where orbiting's bottom-left touches top of stationary
1210        assert!(!contacts.is_empty(), "Should find at least one contact");
1211    }
1212
1213    #[test]
1214    fn test_find_start_position() {
1215        let stationary = rect(10.0, 10.0);
1216        let orbiting = rect(5.0, 5.0);
1217
1218        let start = find_start_position(&stationary, &orbiting).unwrap();
1219
1220        // The orbiting polygon's top should touch stationary's bottom
1221        // Stationary bottom-left is at (0, 0)
1222        // Orbiting top-left is at (0, 5) - so reference should be at (0, -5)
1223        // Actually stationary bottom is y=0, orbiting top is y=5
1224        // So start_pos.1 should be 0 - 5 = -5
1225        assert!(
1226            (start.1 - (-5.0)).abs() < 1e-6,
1227            "Start Y should be -5, got {}",
1228            start.1
1229        );
1230    }
1231
1232    #[test]
1233    fn test_touching_group() {
1234        let ref_pos = (5.0, 5.0);
1235        let mut group = TouchingGroup::new(ref_pos);
1236
1237        assert!(!group.has_contacts());
1238        assert_eq!(group.contact_count(), 0);
1239
1240        group.add_contact(Contact {
1241            contact_type: ContactType::VertexEdge,
1242            stationary_idx: 0,
1243            orbiting_idx: 0,
1244            point: (5.0, 5.0),
1245        });
1246
1247        assert!(group.has_contacts());
1248        assert_eq!(group.contact_count(), 1);
1249    }
1250
1251    #[test]
1252    fn test_compute_translation_vectors() {
1253        let stationary = rect(10.0, 10.0);
1254        let orbiting: Vec<(f64, f64)> = rect(5.0, 5.0)
1255            .iter()
1256            .map(|&(x, y)| (x + 2.5, y + 10.0)) // Touching top edge
1257            .collect();
1258
1259        let group = create_touching_group(&stationary, &orbiting, (2.5, 10.0), 1e-6);
1260        let vectors = compute_translation_vectors(&group, &stationary, &orbiting);
1261
1262        // Should have translation vectors along the contact edges
1263        assert!(!vectors.is_empty(), "Should have translation vectors");
1264    }
1265
1266    #[test]
1267    fn test_sliding_nfp_two_squares() {
1268        let stationary = rect(10.0, 10.0);
1269        let orbiting = rect(5.0, 5.0);
1270
1271        let config = SlidingNfpConfig {
1272            contact_tolerance: 1e-4,
1273            max_iterations: 1000,
1274            min_translation: 1e-6,
1275        };
1276
1277        let result = compute_nfp_sliding(&stationary, &orbiting, &config);
1278
1279        // Should succeed
1280        assert!(result.is_ok(), "NFP computation failed: {:?}", result.err());
1281
1282        let nfp = result.unwrap();
1283        assert!(!nfp.is_empty());
1284
1285        // NFP of two rectangles should have at least 4 vertices
1286        assert!(nfp.vertex_count() >= 4);
1287    }
1288
1289    #[test]
1290    fn test_ensure_ccw() {
1291        // CW square
1292        let cw = vec![(0.0, 0.0), (0.0, 10.0), (10.0, 10.0), (10.0, 0.0)];
1293        let ccw = ensure_ccw(&cw);
1294
1295        // Should be reversed
1296        assert!(signed_area(&ccw) > 0.0);
1297    }
1298
1299    #[test]
1300    fn test_simplify_polygon() {
1301        // Square with an extra collinear point on one edge
1302        let with_extra = vec![
1303            (0.0, 0.0),
1304            (5.0, 0.0), // Collinear point
1305            (10.0, 0.0),
1306            (10.0, 10.0),
1307            (0.0, 10.0),
1308        ];
1309
1310        let simplified = simplify_polygon(&with_extra, 1e-6).unwrap();
1311
1312        // Should remove the collinear point
1313        assert_eq!(simplified.len(), 4, "Simplified should have 4 vertices");
1314    }
1315
1316    #[test]
1317    fn test_polygon_centroid() {
1318        let square = rect(10.0, 10.0);
1319        let centroid = polygon_centroid(&square);
1320
1321        assert!((centroid.0 - 5.0).abs() < 1e-10);
1322        assert!((centroid.1 - 5.0).abs() < 1e-10);
1323    }
1324
1325    // ========================================================================
1326    // Edge Case Tests
1327    // ========================================================================
1328
1329    #[test]
1330    fn test_ray_segment_intersection() {
1331        // Ray pointing right, segment vertical
1332        let result =
1333            ray_segment_intersection((0.0, 0.0), (1.0, 0.0), (5.0, -1.0), (5.0, 1.0), 1e-6);
1334        assert!(result.is_some());
1335        assert!((result.unwrap() - 5.0).abs() < 1e-6);
1336
1337        // Ray pointing left, segment vertical (should not intersect)
1338        let result =
1339            ray_segment_intersection((0.0, 0.0), (-1.0, 0.0), (5.0, -1.0), (5.0, 1.0), 1e-6);
1340        assert!(result.is_none());
1341    }
1342
1343    #[test]
1344    fn test_check_translation_collision() {
1345        // Simple stationary square
1346        let stationary = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
1347
1348        // Orbiting square positioned to the right
1349        let orbiting = vec![(15.0, 2.0), (20.0, 2.0), (20.0, 8.0), (15.0, 8.0)];
1350
1351        // Translation going left (toward the stationary square)
1352        let translation = (-10.0, 0.0);
1353        let collision = check_translation_collision(&stationary, &orbiting, translation, 1e-6);
1354
1355        // Should detect collision - orbiting left edge at x=15 moving -10 would reach x=5
1356        // But stationary right edge is at x=10, so collision should occur at distance 5
1357        assert!(
1358            collision.is_some(),
1359            "Should detect collision when moving left"
1360        );
1361
1362        if let Some(c) = collision {
1363            // Collision distance should be around 5 (from x=15 to x=10)
1364            assert!(
1365                c.distance > 4.0 && c.distance < 6.0,
1366                "Collision distance should be ~5, got {}",
1367                c.distance
1368            );
1369        }
1370    }
1371
1372    #[test]
1373    fn test_polygons_overlap_true() {
1374        // Two overlapping squares
1375        let a = rect(10.0, 10.0);
1376        let b: Vec<(f64, f64)> = rect(10.0, 10.0)
1377            .iter()
1378            .map(|&(x, y)| (x + 5.0, y + 5.0))
1379            .collect();
1380
1381        assert!(
1382            polygons_overlap(&a, &b),
1383            "Overlapping squares should overlap"
1384        );
1385    }
1386
1387    #[test]
1388    fn test_polygons_overlap_false() {
1389        // Two separated squares
1390        let a = rect(10.0, 10.0);
1391        let b: Vec<(f64, f64)> = rect(10.0, 10.0)
1392            .iter()
1393            .map(|&(x, y)| (x + 20.0, y))
1394            .collect();
1395
1396        assert!(
1397            !polygons_overlap(&a, &b),
1398            "Separated squares should not overlap"
1399        );
1400    }
1401
1402    #[test]
1403    fn test_compute_bbox() {
1404        let square = rect(10.0, 10.0);
1405        let bbox = compute_bbox(&square);
1406
1407        assert!((bbox.0 .0 - 0.0).abs() < 1e-10);
1408        assert!((bbox.0 .1 - 0.0).abs() < 1e-10);
1409        assert!((bbox.1 .0 - 10.0).abs() < 1e-10);
1410        assert!((bbox.1 .1 - 10.0).abs() < 1e-10);
1411    }
1412
1413    #[test]
1414    fn test_sliding_nfp_triangle() {
1415        // Equilateral triangle (stationary)
1416        let stationary = vec![(0.0, 0.0), (10.0, 0.0), (5.0, 8.66)];
1417
1418        // Small square (orbiting)
1419        let orbiting = rect(3.0, 3.0);
1420
1421        let config = SlidingNfpConfig {
1422            contact_tolerance: 1e-4,
1423            max_iterations: 1000,
1424            min_translation: 1e-6,
1425        };
1426
1427        let result = compute_nfp_sliding(&stationary, &orbiting, &config);
1428
1429        assert!(result.is_ok(), "NFP of triangle and square should succeed");
1430        let nfp = result.unwrap();
1431        assert!(
1432            nfp.vertex_count() >= 3,
1433            "NFP should have at least 3 vertices"
1434        );
1435    }
1436
1437    #[test]
1438    fn test_sliding_nfp_l_shape() {
1439        // L-shaped polygon (stationary)
1440        let stationary = vec![
1441            (0.0, 0.0),
1442            (20.0, 0.0),
1443            (20.0, 5.0),
1444            (5.0, 5.0),
1445            (5.0, 20.0),
1446            (0.0, 20.0),
1447        ];
1448
1449        // Small square (orbiting)
1450        let orbiting = rect(3.0, 3.0);
1451
1452        let config = SlidingNfpConfig {
1453            contact_tolerance: 1e-4,
1454            max_iterations: 2000,
1455            min_translation: 1e-6,
1456        };
1457
1458        let result = compute_nfp_sliding(&stationary, &orbiting, &config);
1459
1460        assert!(
1461            result.is_ok(),
1462            "NFP of L-shape and square should succeed: {:?}",
1463            result.err()
1464        );
1465        let nfp = result.unwrap();
1466        // L-shape NFP should have more vertices than a simple rectangle
1467        assert!(
1468            nfp.vertex_count() >= 6,
1469            "NFP of L-shape should have >= 6 vertices, got {}",
1470            nfp.vertex_count()
1471        );
1472    }
1473
1474    #[test]
1475    fn test_handle_perfect_fit_filters_visited() {
1476        let stationary = rect(10.0, 10.0);
1477        let orbiting: Vec<(f64, f64)> =
1478            rect(5.0, 5.0).iter().map(|&(x, y)| (x, y + 10.0)).collect();
1479
1480        let group = create_touching_group(&stationary, &orbiting, (0.0, 10.0), 1e-4);
1481
1482        // Visited positions include nearby points
1483        let visited = vec![(0.5, 10.0), (0.0, 10.5)];
1484
1485        let vectors = handle_perfect_fit(&group, &stationary, &orbiting, &visited, 1e-4);
1486
1487        // Should filter out vectors leading to visited positions
1488        // The exact count depends on geometry, but should be fewer than unfiltered
1489        let unfiltered = compute_translation_vectors(&group, &stationary, &orbiting);
1490
1491        // In some cases they might be equal if no filtering happens
1492        // Just verify it doesn't crash
1493        assert!(vectors.len() <= unfiltered.len());
1494    }
1495
1496    #[test]
1497    fn test_detect_interlocking_concave_polygon() {
1498        // C-shaped polygon with concavity
1499        let stationary = vec![
1500            (0.0, 0.0),
1501            (20.0, 0.0),
1502            (20.0, 5.0),
1503            (5.0, 5.0),
1504            (5.0, 15.0),
1505            (20.0, 15.0),
1506            (20.0, 20.0),
1507            (0.0, 20.0),
1508        ];
1509
1510        // Small square
1511        let orbiting = rect(3.0, 3.0);
1512
1513        // Position outside the C-shape
1514        let current_pos = (25.0, 10.0);
1515
1516        // This might or might not detect an opportunity depending on geometry
1517        let result = detect_interlocking_opportunity(&stationary, &orbiting, current_pos, 1e-4);
1518
1519        // Just verify it doesn't crash - the logic is complex
1520        // Result can be Some or None depending on exact geometry
1521        let _ = result;
1522    }
1523
1524    #[test]
1525    fn test_sliding_nfp_same_size_squares() {
1526        // Two identical squares
1527        let stationary = rect(10.0, 10.0);
1528        let orbiting = rect(10.0, 10.0);
1529
1530        let config = SlidingNfpConfig {
1531            contact_tolerance: 1e-4,
1532            max_iterations: 1000,
1533            min_translation: 1e-6,
1534        };
1535
1536        let result = compute_nfp_sliding(&stationary, &orbiting, &config);
1537
1538        assert!(result.is_ok(), "NFP of same-size squares should succeed");
1539        let nfp = result.unwrap();
1540
1541        // NFP of two identical squares should be a square with side 2x original
1542        // The area should be roughly 4x (since sides double)
1543        assert!(nfp.vertex_count() >= 4);
1544    }
1545
1546    #[test]
1547    fn test_signed_area_ccw() {
1548        let ccw = rect(10.0, 10.0);
1549        let area = signed_area(&ccw);
1550        assert!(area > 0.0, "CCW polygon should have positive signed area");
1551    }
1552
1553    #[test]
1554    fn test_signed_area_cw() {
1555        let cw = vec![(0.0, 0.0), (0.0, 10.0), (10.0, 10.0), (10.0, 0.0)];
1556        let area = signed_area(&cw);
1557        assert!(area < 0.0, "CW polygon should have negative signed area");
1558    }
1559}