Skip to main content

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