u_nesting_d2/
nester.rs

1//! 2D nesting solver.
2
3use crate::alns_nesting::run_alns_nesting;
4use crate::boundary::Boundary2D;
5use crate::brkga_nesting::run_brkga_nesting;
6use crate::clamp_placement_to_boundary_with_margin;
7use crate::ga_nesting::{run_ga_nesting, run_ga_nesting_with_progress};
8use crate::gdrr_nesting::run_gdrr_nesting;
9use crate::geometry::Geometry2D;
10#[cfg(feature = "milp")]
11use crate::milp_solver::run_milp_nesting;
12use crate::nfp::{
13    compute_ifp_with_margin, compute_nfp, find_bottom_left_placement, rotate_nfp, translate_nfp,
14    Nfp, NfpCache, PlacedGeometry,
15};
16#[cfg(feature = "milp")]
17#[allow(unused_imports)]
18use crate::nfp_cm_solver::run_nfp_cm_nesting;
19use crate::sa_nesting::run_sa_nesting;
20use crate::validate_and_filter_placements;
21use u_nesting_core::alns::AlnsConfig;
22use u_nesting_core::brkga::BrkgaConfig;
23#[cfg(feature = "milp")]
24use u_nesting_core::exact::ExactConfig;
25use u_nesting_core::ga::GaConfig;
26use u_nesting_core::gdrr::GdrrConfig;
27use u_nesting_core::geometry::{Boundary, Geometry};
28use u_nesting_core::sa::SaConfig;
29use u_nesting_core::solver::{Config, ProgressCallback, ProgressInfo, Solver, Strategy};
30use u_nesting_core::{Placement, Result, SolveResult};
31
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::sync::Arc;
34use std::time::Instant;
35
36/// 2D nesting solver.
37pub struct Nester2D {
38    config: Config,
39    cancelled: Arc<AtomicBool>,
40    #[allow(dead_code)] // Will be used for caching in future optimization
41    nfp_cache: NfpCache,
42}
43
44impl Nester2D {
45    /// Creates a new nester with the given configuration.
46    pub fn new(config: Config) -> Self {
47        Self {
48            config,
49            cancelled: Arc::new(AtomicBool::new(false)),
50            nfp_cache: NfpCache::new(),
51        }
52    }
53
54    /// Creates a nester with default configuration.
55    pub fn default_config() -> Self {
56        Self::new(Config::default())
57    }
58
59    /// Bottom-Left Fill algorithm implementation with rotation optimization.
60    fn bottom_left_fill(
61        &self,
62        geometries: &[Geometry2D],
63        boundary: &Boundary2D,
64    ) -> Result<SolveResult<f64>> {
65        let start = Instant::now();
66        let mut result = SolveResult::new();
67        let mut placements = Vec::new();
68
69        // Get boundary dimensions
70        let (b_min, b_max) = boundary.aabb();
71        let margin = self.config.margin;
72        let spacing = self.config.spacing;
73
74        let bound_min_x = b_min[0] + margin;
75        let bound_min_y = b_min[1] + margin;
76        let bound_max_x = b_max[0] - margin;
77        let bound_max_y = b_max[1] - margin;
78
79        let strip_width = bound_max_x - bound_min_x;
80        let strip_height = bound_max_y - bound_min_y;
81
82        // Simple row-based placement with rotation optimization
83        let mut current_x = bound_min_x;
84        let mut current_y = bound_min_y;
85        let mut row_height = 0.0_f64;
86
87        let mut total_placed_area = 0.0;
88
89        for geom in geometries {
90            geom.validate()?;
91
92            // Get allowed rotation angles (default to 0 if none specified)
93            let rotations = geom.rotations();
94            let rotation_angles: Vec<f64> = if rotations.is_empty() {
95                vec![0.0]
96            } else {
97                rotations
98            };
99
100            for instance in 0..geom.quantity() {
101                if self.cancelled.load(Ordering::Relaxed) {
102                    result.computation_time_ms = start.elapsed().as_millis() as u64;
103                    return Ok(result);
104                }
105
106                // Check time limit (0 = unlimited)
107                if self.config.time_limit_ms > 0
108                    && start.elapsed().as_millis() as u64 >= self.config.time_limit_ms
109                {
110                    result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
111                    result.utilization = total_placed_area / boundary.measure();
112                    result.computation_time_ms = start.elapsed().as_millis() as u64;
113                    result.placements = placements;
114                    return Ok(result);
115                }
116
117                // Find the best rotation for current position
118                let mut best_fit: Option<(f64, f64, f64, f64, f64, [f64; 2])> = None; // (rotation, width, height, x, y, g_min)
119
120                for &rotation in &rotation_angles {
121                    let (g_min, g_max) = geom.aabb_at_rotation(rotation);
122                    let g_width = g_max[0] - g_min[0];
123                    let g_height = g_max[1] - g_min[1];
124
125                    // Skip if geometry doesn't fit in boundary at all
126                    if g_width > strip_width || g_height > strip_height {
127                        continue;
128                    }
129
130                    // Calculate placement position for this rotation
131                    let mut place_x = current_x;
132                    let mut place_y = current_y;
133
134                    // Check if piece fits in remaining row space
135                    if place_x + g_width > bound_max_x {
136                        // Would need to move to next row
137                        place_x = bound_min_x;
138                        place_y += row_height + spacing;
139                    }
140
141                    // Check if piece fits in boundary height
142                    if place_y + g_height > bound_max_y {
143                        continue; // This rotation doesn't fit
144                    }
145
146                    // Calculate score: prefer rotations that minimize wasted space
147                    // Score = row advancement (lower is better)
148                    let score = if place_x == bound_min_x && place_y > current_y {
149                        // New row: score is based on new Y position
150                        place_y - bound_min_y + g_height
151                    } else {
152                        // Same row: score is based on strip length advancement
153                        place_x - bound_min_x + g_width
154                    };
155
156                    let is_better = match &best_fit {
157                        None => true,
158                        Some((_, _, _, _, _, _)) => {
159                            // Prefer placements that don't start new rows
160                            let best_score = if let Some((_, _, _, bx, by, _)) = best_fit {
161                                if bx == bound_min_x && by > current_y {
162                                    by - bound_min_y + g_height
163                                } else {
164                                    bx - bound_min_x + g_width
165                                }
166                            } else {
167                                f64::INFINITY
168                            };
169                            score < best_score - 1e-6
170                        }
171                    };
172
173                    if is_better {
174                        best_fit = Some((rotation, g_width, g_height, place_x, place_y, g_min));
175                    }
176                }
177
178                // Place the geometry with the best rotation
179                if let Some((rotation, g_width, g_height, place_x, place_y, g_min)) = best_fit {
180                    // Update row tracking if we moved to a new row
181                    if place_x == bound_min_x && place_y > current_y {
182                        row_height = 0.0;
183                    }
184
185                    // Compute origin position from AABB position
186                    let origin_x = place_x - g_min[0];
187                    let origin_y = place_y - g_min[1];
188
189                    // Clamp to ensure geometry stays within boundary
190                    let geom_aabb = geom.aabb_at_rotation(rotation);
191                    let boundary_aabb = (b_min, b_max);
192
193                    if let Some((clamped_x, clamped_y)) = clamp_placement_to_boundary_with_margin(
194                        origin_x,
195                        origin_y,
196                        geom_aabb,
197                        boundary_aabb,
198                        margin,
199                    ) {
200                        let placement = Placement::new_2d(
201                            geom.id().clone(),
202                            instance,
203                            clamped_x,
204                            clamped_y,
205                            rotation,
206                        );
207
208                        placements.push(placement);
209                        total_placed_area += geom.measure();
210
211                        // Update position for next piece
212                        // Use actual clamped AABB position, not original place_x/place_y
213                        let actual_place_x = clamped_x + g_min[0];
214                        let actual_place_y = clamped_y + g_min[1];
215                        current_x = actual_place_x + g_width + spacing;
216                        current_y = actual_place_y;
217                        row_height = row_height.max(g_height);
218                    }
219                } else {
220                    // Can't place this piece with any rotation
221                    result.unplaced.push(geom.id().clone());
222                }
223            }
224        }
225
226        result.placements = placements;
227        result.boundaries_used = 1;
228        result.utilization = total_placed_area / boundary.measure();
229        result.computation_time_ms = start.elapsed().as_millis() as u64;
230
231        Ok(result)
232    }
233
234    /// NFP-guided Bottom-Left Fill algorithm.
235    ///
236    /// Uses No-Fit Polygons to find optimal placement positions that minimize
237    /// wasted space while ensuring no overlaps.
238    fn nfp_guided_blf(
239        &self,
240        geometries: &[Geometry2D],
241        boundary: &Boundary2D,
242    ) -> Result<SolveResult<f64>> {
243        let start = Instant::now();
244        let mut result = SolveResult::new();
245        let mut placements = Vec::new();
246        let mut placed_geometries: Vec<PlacedGeometry> = Vec::new();
247
248        let margin = self.config.margin;
249        let spacing = self.config.spacing;
250
251        // Get boundary polygon with margin applied
252        let boundary_polygon = self.get_boundary_polygon_with_margin(boundary, margin);
253
254        let mut total_placed_area = 0.0;
255
256        // Sampling step for grid search (adaptive based on geometry size)
257        let sample_step = self.compute_sample_step(geometries);
258
259        for geom in geometries {
260            geom.validate()?;
261
262            // Get allowed rotation angles
263            let rotations = geom.rotations();
264            let rotation_angles: Vec<f64> = if rotations.is_empty() {
265                vec![0.0]
266            } else {
267                rotations
268            };
269
270            for instance in 0..geom.quantity() {
271                if self.cancelled.load(Ordering::Relaxed) {
272                    result.computation_time_ms = start.elapsed().as_millis() as u64;
273                    return Ok(result);
274                }
275
276                // Check time limit (0 = unlimited)
277                if self.config.time_limit_ms > 0
278                    && start.elapsed().as_millis() as u64 >= self.config.time_limit_ms
279                {
280                    result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
281                    result.utilization = total_placed_area / boundary.measure();
282                    result.computation_time_ms = start.elapsed().as_millis() as u64;
283                    result.placements = placements;
284                    return Ok(result);
285                }
286
287                // Try each rotation angle to find the best placement
288                let mut best_placement: Option<(f64, f64, f64)> = None; // (x, y, rotation)
289
290                for &rotation in &rotation_angles {
291                    // Compute IFP for this geometry at this rotation (with margin from boundary)
292                    let ifp =
293                        match compute_ifp_with_margin(&boundary_polygon, geom, rotation, margin) {
294                            Ok(ifp) => ifp,
295                            Err(_) => continue,
296                        };
297
298                    if ifp.is_empty() {
299                        continue;
300                    }
301
302                    // Compute NFPs with all placed geometries (using cache)
303                    let mut nfps: Vec<Nfp> = Vec::new();
304                    for placed in &placed_geometries {
305                        // Use cache for NFP computation (between original geometries at origin)
306                        // Key: (placed_geometry_id, current_geometry_id, rotation)
307                        let cache_key = (
308                            placed.geometry.id().as_str(),
309                            geom.id().as_str(),
310                            rotation - placed.rotation, // Relative rotation
311                        );
312
313                        // Compute NFP at origin and cache it (with relative rotation)
314                        // NFP is computed between the placed geometry at origin (no rotation)
315                        // and the new geometry with relative rotation applied.
316                        // Formula: NFP_actual = translate(rotate(NFP_relative, placed.rotation), placed.position)
317                        let nfp_at_origin = match self.nfp_cache.get_or_compute(cache_key, || {
318                            let placed_at_origin = placed.geometry.clone();
319                            compute_nfp(&placed_at_origin, geom, rotation - placed.rotation)
320                        }) {
321                            Ok(nfp) => nfp,
322                            Err(_) => continue,
323                        };
324
325                        // Transform NFP: first rotate by placed.rotation, then translate to placed.position
326                        // This correctly accounts for the placed geometry's actual orientation
327                        let rotated_nfp = rotate_nfp(&nfp_at_origin, placed.rotation);
328                        let translated_nfp = translate_nfp(&rotated_nfp, placed.position);
329                        let expanded = self.expand_nfp(&translated_nfp, spacing);
330                        nfps.push(expanded);
331                    }
332
333                    // Shrink IFP by spacing from boundary
334                    let ifp_shrunk = self.shrink_ifp(&ifp, spacing);
335
336                    // Find the optimal valid placement (minimize X for shorter strip)
337                    let nfp_refs: Vec<&Nfp> = nfps.iter().collect();
338                    if let Some((x, y)) =
339                        find_bottom_left_placement(&ifp_shrunk, &nfp_refs, sample_step)
340                    {
341                        // Compare with current best: prefer smaller X (shorter strip), then smaller Y
342                        let is_better = match best_placement {
343                            None => true,
344                            Some((best_x, best_y, _)) => {
345                                x < best_x - 1e-6 || (x < best_x + 1e-6 && y < best_y - 1e-6)
346                            }
347                        };
348                        if is_better {
349                            best_placement = Some((x, y, rotation));
350                        }
351                    }
352                }
353
354                // Place the geometry at the best position found
355                if let Some((x, y, rotation)) = best_placement {
356                    // Clamp to ensure geometry stays within boundary
357                    let geom_aabb = geom.aabb_at_rotation(rotation);
358                    let boundary_aabb = boundary.aabb();
359
360                    if let Some((clamped_x, clamped_y)) = clamp_placement_to_boundary_with_margin(
361                        x,
362                        y,
363                        geom_aabb,
364                        boundary_aabb,
365                        margin,
366                    ) {
367                        let placement = Placement::new_2d(
368                            geom.id().clone(),
369                            instance,
370                            clamped_x,
371                            clamped_y,
372                            rotation,
373                        );
374
375                        placements.push(placement);
376                        placed_geometries.push(PlacedGeometry::new(
377                            geom.clone(),
378                            (clamped_x, clamped_y),
379                            rotation,
380                        ));
381                        total_placed_area += geom.measure();
382                    } else {
383                        // Could not place - geometry doesn't fit
384                        result.unplaced.push(geom.id().clone());
385                    }
386                } else {
387                    // Could not place this instance
388                    result.unplaced.push(geom.id().clone());
389                }
390            }
391        }
392
393        result.placements = placements;
394        result.boundaries_used = 1;
395        result.utilization = total_placed_area / boundary.measure();
396        result.computation_time_ms = start.elapsed().as_millis() as u64;
397
398        Ok(result)
399    }
400
401    /// Gets the boundary polygon with margin applied.
402    fn get_boundary_polygon_with_margin(
403        &self,
404        boundary: &Boundary2D,
405        margin: f64,
406    ) -> Vec<(f64, f64)> {
407        let (b_min, b_max) = boundary.aabb();
408
409        // Create a rectangular boundary polygon with margin
410        vec![
411            (b_min[0] + margin, b_min[1] + margin),
412            (b_max[0] - margin, b_min[1] + margin),
413            (b_max[0] - margin, b_max[1] - margin),
414            (b_min[0] + margin, b_max[1] - margin),
415        ]
416    }
417
418    /// Computes an adaptive sample step based on geometry sizes.
419    fn compute_sample_step(&self, geometries: &[Geometry2D]) -> f64 {
420        if geometries.is_empty() {
421            return 1.0;
422        }
423
424        // Use the smallest geometry dimension divided by 4 as sample step
425        let mut min_dim = f64::INFINITY;
426        for geom in geometries {
427            let (g_min, g_max) = geom.aabb();
428            let width = g_max[0] - g_min[0];
429            let height = g_max[1] - g_min[1];
430            min_dim = min_dim.min(width).min(height);
431        }
432
433        // Clamp sample step to reasonable range
434        (min_dim / 4.0).clamp(0.5, 10.0)
435    }
436
437    /// Expands an NFP by the given spacing amount.
438    fn expand_nfp(&self, nfp: &Nfp, spacing: f64) -> Nfp {
439        if spacing <= 0.0 {
440            return nfp.clone();
441        }
442
443        // Simple expansion: offset each polygon outward
444        // For a proper implementation, this should use polygon offsetting
445        // For now, we use a conservative approximation
446        let expanded_polygons: Vec<Vec<(f64, f64)>> = nfp
447            .polygons
448            .iter()
449            .map(|polygon| {
450                // Compute centroid
451                let (cx, cy) = polygon_centroid(polygon);
452
453                // Offset each vertex away from centroid
454                polygon
455                    .iter()
456                    .map(|&(x, y)| {
457                        let dx = x - cx;
458                        let dy = y - cy;
459                        let dist = (dx * dx + dy * dy).sqrt();
460                        if dist > 1e-10 {
461                            let scale = (dist + spacing) / dist;
462                            (cx + dx * scale, cy + dy * scale)
463                        } else {
464                            (x, y)
465                        }
466                    })
467                    .collect()
468            })
469            .collect();
470
471        Nfp::from_polygons(expanded_polygons)
472    }
473
474    /// Shrinks an IFP by the given spacing amount.
475    fn shrink_ifp(&self, ifp: &Nfp, spacing: f64) -> Nfp {
476        if spacing <= 0.0 {
477            return ifp.clone();
478        }
479
480        // Simple shrinking: offset each polygon inward
481        let shrunk_polygons: Vec<Vec<(f64, f64)>> = ifp
482            .polygons
483            .iter()
484            .filter_map(|polygon| {
485                // Compute centroid
486                let (cx, cy) = polygon_centroid(polygon);
487
488                // Offset each vertex toward centroid
489                let shrunk: Vec<(f64, f64)> = polygon
490                    .iter()
491                    .map(|&(x, y)| {
492                        let dx = x - cx;
493                        let dy = y - cy;
494                        let dist = (dx * dx + dy * dy).sqrt();
495                        if dist > spacing + 1e-10 {
496                            let scale = (dist - spacing) / dist;
497                            (cx + dx * scale, cy + dy * scale)
498                        } else {
499                            // Point too close to centroid, collapse to centroid
500                            (cx, cy)
501                        }
502                    })
503                    .collect();
504
505                // Only keep polygon if it still has area
506                if shrunk.len() >= 3 {
507                    Some(shrunk)
508                } else {
509                    None
510                }
511            })
512            .collect();
513
514        Nfp::from_polygons(shrunk_polygons)
515    }
516
517    /// Genetic Algorithm based nesting optimization.
518    ///
519    /// Uses GA to optimize placement order and rotations, with NFP-guided
520    /// decoding for collision-free placements.
521    fn genetic_algorithm(
522        &self,
523        geometries: &[Geometry2D],
524        boundary: &Boundary2D,
525    ) -> Result<SolveResult<f64>> {
526        // Configure GA with time limit for multi-strip scenarios
527        let time_limit_ms = if self.config.time_limit_ms > 0 {
528            // Use 1/4 of total time limit per strip to allow for multiple strips
529            (self.config.time_limit_ms / 4).max(5000)
530        } else {
531            15000 // 15 seconds default per strip
532        };
533
534        let ga_config = GaConfig::default()
535            .with_population_size(self.config.population_size.min(30)) // Limit population
536            .with_max_generations(self.config.max_generations.min(50)) // Limit generations
537            .with_crossover_rate(self.config.crossover_rate)
538            .with_mutation_rate(self.config.mutation_rate)
539            .with_time_limit(std::time::Duration::from_millis(time_limit_ms));
540
541        let result = run_ga_nesting(
542            geometries,
543            boundary,
544            &self.config,
545            ga_config,
546            self.cancelled.clone(),
547        );
548
549        Ok(result)
550    }
551
552    /// BRKGA (Biased Random-Key Genetic Algorithm) based nesting optimization.
553    ///
554    /// Uses random-key encoding and biased crossover for robust optimization.
555    fn brkga(&self, geometries: &[Geometry2D], boundary: &Boundary2D) -> Result<SolveResult<f64>> {
556        // Configure BRKGA with time limit for multi-strip scenarios
557        let time_limit_ms = if self.config.time_limit_ms > 0 {
558            // Use 1/4 of total time limit per strip to allow for multiple strips
559            (self.config.time_limit_ms / 4).max(5000)
560        } else {
561            15000 // 15 seconds default per strip
562        };
563
564        let brkga_config = BrkgaConfig::default()
565            .with_population_size(30) // Smaller population for speed
566            .with_max_generations(50) // Fewer generations
567            .with_elite_fraction(0.2)
568            .with_mutant_fraction(0.15)
569            .with_elite_bias(0.7)
570            .with_time_limit(std::time::Duration::from_millis(time_limit_ms));
571
572        let result = run_brkga_nesting(
573            geometries,
574            boundary,
575            &self.config,
576            brkga_config,
577            self.cancelled.clone(),
578        );
579
580        Ok(result)
581    }
582
583    /// Simulated Annealing based nesting optimization.
584    ///
585    /// Uses neighborhood operators to explore solution space with temperature-based
586    /// acceptance probability.
587    fn simulated_annealing(
588        &self,
589        geometries: &[Geometry2D],
590        boundary: &Boundary2D,
591    ) -> Result<SolveResult<f64>> {
592        // Configure SA with faster defaults for multi-strip scenarios
593        // Note: Each decode() call is O(N²) NFP computations, so we need fewer iterations
594        let time_limit_ms = if self.config.time_limit_ms > 0 {
595            // Use 1/4 of total time limit per strip to allow for multiple strips
596            (self.config.time_limit_ms / 4).max(5000)
597        } else {
598            10000 // 10 seconds default per strip
599        };
600
601        let sa_config = SaConfig::default()
602            .with_initial_temp(50.0) // Lower initial temp for faster convergence
603            .with_final_temp(1.0) // Higher final temp to finish faster
604            .with_cooling_rate(0.9) // Faster cooling (was 0.95)
605            .with_iterations_per_temp(20) // Fewer iterations per temp (was 50)
606            .with_max_iterations(500) // Much fewer max iterations (was 10000)
607            .with_time_limit(std::time::Duration::from_millis(time_limit_ms));
608
609        let result = run_sa_nesting(
610            geometries,
611            boundary,
612            &self.config,
613            sa_config,
614            self.cancelled.clone(),
615        );
616
617        Ok(result)
618    }
619
620    /// Goal-Driven Ruin and Recreate (GDRR) optimization.
621    fn gdrr(&self, geometries: &[Geometry2D], boundary: &Boundary2D) -> Result<SolveResult<f64>> {
622        // Configure GDRR with faster defaults for multi-strip scenarios
623        // Use user's time limit, default to 10s per strip if not specified
624        let time_limit = if self.config.time_limit_ms > 0 {
625            // Use 1/4 of total time limit per strip to allow for multiple strips
626            (self.config.time_limit_ms / 4).max(5000)
627        } else {
628            10000 // 10 seconds default per strip
629        };
630        let gdrr_config = GdrrConfig::default()
631            .with_max_iterations(1000) // Reduced from 5000 for faster execution
632            .with_time_limit_ms(time_limit)
633            .with_ruin_ratio(0.1, 0.3) // Smaller ruin ratio for faster convergence
634            .with_lahc_list_length(30); // Smaller list for faster convergence
635
636        let result = run_gdrr_nesting(
637            geometries,
638            boundary,
639            &self.config,
640            &gdrr_config,
641            self.cancelled.clone(),
642        );
643
644        Ok(result)
645    }
646
647    /// Adaptive Large Neighborhood Search (ALNS) optimization.
648    fn alns(&self, geometries: &[Geometry2D], boundary: &Boundary2D) -> Result<SolveResult<f64>> {
649        // Configure ALNS with faster defaults for multi-strip scenarios
650        // Use user's time limit, default to 10s per strip if not specified
651        let time_limit = if self.config.time_limit_ms > 0 {
652            // Use 1/4 of total time limit per strip to allow for multiple strips
653            (self.config.time_limit_ms / 4).max(5000)
654        } else {
655            10000 // 10 seconds default per strip
656        };
657        let alns_config = AlnsConfig::default()
658            .with_max_iterations(1000) // Reduced from 5000 for faster execution
659            .with_time_limit_ms(time_limit)
660            .with_segment_size(50) // Smaller segments for faster adaptation
661            .with_scores(33.0, 9.0, 13.0)
662            .with_reaction_factor(0.15) // Slightly higher for faster adaptation
663            .with_temperature(100.0, 0.999, 0.1); // Faster cooling
664
665        let result = run_alns_nesting(
666            geometries,
667            boundary,
668            &self.config,
669            &alns_config,
670            self.cancelled.clone(),
671        );
672
673        Ok(result)
674    }
675
676    /// MILP-based exact solver.
677    #[cfg(feature = "milp")]
678    fn milp_exact(
679        &self,
680        geometries: &[Geometry2D],
681        boundary: &Boundary2D,
682    ) -> Result<SolveResult<f64>> {
683        let exact_config = ExactConfig::default()
684            .with_time_limit_ms(self.config.time_limit_ms.max(60000))
685            .with_max_items(15)
686            .with_rotation_steps(4)
687            .with_grid_step(1.0);
688
689        let result = run_milp_nesting(
690            geometries,
691            boundary,
692            &self.config,
693            &exact_config,
694            self.cancelled.clone(),
695        );
696
697        Ok(result)
698    }
699
700    /// Hybrid exact solver: try MILP first, fallback to heuristic.
701    #[cfg(feature = "milp")]
702    fn hybrid_exact(
703        &self,
704        geometries: &[Geometry2D],
705        boundary: &Boundary2D,
706    ) -> Result<SolveResult<f64>> {
707        // Count total instances
708        let total_instances: usize = geometries.iter().map(|g| g.quantity()).sum();
709
710        // If small enough, try exact
711        if total_instances <= 15 {
712            let exact_config = ExactConfig::default()
713                .with_time_limit_ms((self.config.time_limit_ms / 2).max(30000))
714                .with_max_items(15);
715
716            let exact_result = run_milp_nesting(
717                geometries,
718                boundary,
719                &self.config,
720                &exact_config,
721                self.cancelled.clone(),
722            );
723
724            // If got a good solution, return it
725            if !exact_result.placements.is_empty() {
726                return Ok(exact_result);
727            }
728        }
729
730        // Fallback to ALNS (best heuristic)
731        self.alns(geometries, boundary)
732    }
733
734    /// Bottom-Left Fill with progress callback.
735    fn bottom_left_fill_with_progress(
736        &self,
737        geometries: &[Geometry2D],
738        boundary: &Boundary2D,
739        callback: &ProgressCallback,
740    ) -> Result<SolveResult<f64>> {
741        let start = Instant::now();
742        let mut result = SolveResult::new();
743        let mut placements = Vec::new();
744
745        // Get boundary dimensions
746        let (b_min, b_max) = boundary.aabb();
747        let margin = self.config.margin;
748        let spacing = self.config.spacing;
749
750        let bound_min_x = b_min[0] + margin;
751        let bound_min_y = b_min[1] + margin;
752        let bound_max_x = b_max[0] - margin;
753        let bound_max_y = b_max[1] - margin;
754
755        let strip_width = bound_max_x - bound_min_x;
756        let strip_height = bound_max_y - bound_min_y;
757
758        let mut current_x = bound_min_x;
759        let mut current_y = bound_min_y;
760        let mut row_height = 0.0_f64;
761        let mut total_placed_area = 0.0;
762
763        // Count total pieces for progress
764        let total_pieces: usize = geometries.iter().map(|g| g.quantity()).sum();
765        let mut placed_count = 0usize;
766
767        // Initial progress callback
768        callback(
769            ProgressInfo::new()
770                .with_phase("BLF Placement")
771                .with_items(0, total_pieces)
772                .with_elapsed(0),
773        );
774
775        for geom in geometries {
776            geom.validate()?;
777
778            let rotations = geom.rotations();
779            let rotation_angles: Vec<f64> = if rotations.is_empty() {
780                vec![0.0]
781            } else {
782                rotations
783            };
784
785            for instance in 0..geom.quantity() {
786                if self.cancelled.load(Ordering::Relaxed) {
787                    result.computation_time_ms = start.elapsed().as_millis() as u64;
788                    callback(
789                        ProgressInfo::new()
790                            .with_phase("Cancelled")
791                            .with_items(placed_count, total_pieces)
792                            .with_elapsed(result.computation_time_ms)
793                            .finished(),
794                    );
795                    return Ok(result);
796                }
797
798                // Check time limit (0 = unlimited)
799                if self.config.time_limit_ms > 0
800                    && start.elapsed().as_millis() as u64 >= self.config.time_limit_ms
801                {
802                    result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
803                    result.utilization = total_placed_area / boundary.measure();
804                    result.computation_time_ms = start.elapsed().as_millis() as u64;
805                    result.placements = placements;
806                    callback(
807                        ProgressInfo::new()
808                            .with_phase("Time Limit Reached")
809                            .with_items(placed_count, total_pieces)
810                            .with_elapsed(result.computation_time_ms)
811                            .finished(),
812                    );
813                    return Ok(result);
814                }
815
816                let mut best_fit: Option<(f64, f64, f64, f64, f64, [f64; 2])> = None;
817
818                for &rotation in &rotation_angles {
819                    let (g_min, g_max) = geom.aabb_at_rotation(rotation);
820                    let g_width = g_max[0] - g_min[0];
821                    let g_height = g_max[1] - g_min[1];
822
823                    if g_width > strip_width || g_height > strip_height {
824                        continue;
825                    }
826
827                    let mut place_x = current_x;
828                    let mut place_y = current_y;
829
830                    if place_x + g_width > bound_max_x {
831                        place_x = bound_min_x;
832                        place_y += row_height + spacing;
833                    }
834
835                    if place_y + g_height > bound_max_y {
836                        continue;
837                    }
838
839                    let score = if place_x == bound_min_x && place_y > current_y {
840                        place_y - bound_min_y + g_height
841                    } else {
842                        place_x - bound_min_x + g_width
843                    };
844
845                    let is_better = match &best_fit {
846                        None => true,
847                        Some((_, _, _, bx, by, _)) => {
848                            let best_score = if *bx == bound_min_x && *by > current_y {
849                                by - bound_min_y
850                            } else {
851                                bx - bound_min_x
852                            };
853                            score < best_score - 1e-6
854                        }
855                    };
856
857                    if is_better {
858                        best_fit = Some((rotation, g_width, g_height, place_x, place_y, g_min));
859                    }
860                }
861
862                if let Some((rotation, g_width, g_height, place_x, place_y, g_min)) = best_fit {
863                    if place_x == bound_min_x && place_y > current_y {
864                        row_height = 0.0;
865                    }
866
867                    // Compute origin position from AABB position
868                    let origin_x = place_x - g_min[0];
869                    let origin_y = place_y - g_min[1];
870
871                    // Clamp to ensure geometry stays within boundary
872                    let geom_aabb = geom.aabb_at_rotation(rotation);
873                    let boundary_aabb = (b_min, b_max);
874
875                    if let Some((clamped_x, clamped_y)) = clamp_placement_to_boundary_with_margin(
876                        origin_x,
877                        origin_y,
878                        geom_aabb,
879                        boundary_aabb,
880                        margin,
881                    ) {
882                        let placement = Placement::new_2d(
883                            geom.id().clone(),
884                            instance,
885                            clamped_x,
886                            clamped_y,
887                            rotation,
888                        );
889
890                        placements.push(placement);
891                        total_placed_area += geom.measure();
892                        placed_count += 1;
893
894                        current_x = place_x + g_width + spacing;
895                        current_y = place_y;
896                        row_height = row_height.max(g_height);
897
898                        // Progress callback every piece
899                        callback(
900                            ProgressInfo::new()
901                                .with_phase("BLF Placement")
902                                .with_items(placed_count, total_pieces)
903                                .with_utilization(total_placed_area / boundary.measure())
904                                .with_elapsed(start.elapsed().as_millis() as u64),
905                        );
906                    } else {
907                        result.unplaced.push(geom.id().clone());
908                    }
909                } else {
910                    result.unplaced.push(geom.id().clone());
911                }
912            }
913        }
914
915        result.placements = placements;
916        result.boundaries_used = 1;
917        result.utilization = total_placed_area / boundary.measure();
918        result.computation_time_ms = start.elapsed().as_millis() as u64;
919
920        // Final progress callback
921        callback(
922            ProgressInfo::new()
923                .with_phase("Complete")
924                .with_items(placed_count, total_pieces)
925                .with_utilization(result.utilization)
926                .with_elapsed(result.computation_time_ms)
927                .finished(),
928        );
929
930        Ok(result)
931    }
932
933    /// NFP-guided BLF with progress callback.
934    fn nfp_guided_blf_with_progress(
935        &self,
936        geometries: &[Geometry2D],
937        boundary: &Boundary2D,
938        callback: &ProgressCallback,
939    ) -> Result<SolveResult<f64>> {
940        let start = Instant::now();
941        let mut result = SolveResult::new();
942        let mut placements = Vec::new();
943        let mut placed_geometries: Vec<PlacedGeometry> = Vec::new();
944
945        let margin = self.config.margin;
946        let spacing = self.config.spacing;
947        let boundary_polygon = self.get_boundary_polygon_with_margin(boundary, margin);
948
949        let mut total_placed_area = 0.0;
950        let sample_step = self.compute_sample_step(geometries);
951
952        // Count total pieces for progress
953        let total_pieces: usize = geometries.iter().map(|g| g.quantity()).sum();
954        let mut placed_count = 0usize;
955
956        // Initial progress callback
957        callback(
958            ProgressInfo::new()
959                .with_phase("NFP Placement")
960                .with_items(0, total_pieces)
961                .with_elapsed(0),
962        );
963
964        for geom in geometries {
965            geom.validate()?;
966
967            let rotations = geom.rotations();
968            let rotation_angles: Vec<f64> = if rotations.is_empty() {
969                vec![0.0]
970            } else {
971                rotations
972            };
973
974            for instance in 0..geom.quantity() {
975                if self.cancelled.load(Ordering::Relaxed) {
976                    result.computation_time_ms = start.elapsed().as_millis() as u64;
977                    callback(
978                        ProgressInfo::new()
979                            .with_phase("Cancelled")
980                            .with_items(placed_count, total_pieces)
981                            .with_elapsed(result.computation_time_ms)
982                            .finished(),
983                    );
984                    return Ok(result);
985                }
986
987                // Check time limit (0 = unlimited)
988                if self.config.time_limit_ms > 0
989                    && start.elapsed().as_millis() as u64 >= self.config.time_limit_ms
990                {
991                    result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
992                    result.utilization = total_placed_area / boundary.measure();
993                    result.computation_time_ms = start.elapsed().as_millis() as u64;
994                    result.placements = placements;
995                    callback(
996                        ProgressInfo::new()
997                            .with_phase("Time Limit Reached")
998                            .with_items(placed_count, total_pieces)
999                            .with_elapsed(result.computation_time_ms)
1000                            .finished(),
1001                    );
1002                    return Ok(result);
1003                }
1004
1005                let mut best_placement: Option<(f64, f64, f64)> = None;
1006
1007                for &rotation in &rotation_angles {
1008                    let ifp =
1009                        match compute_ifp_with_margin(&boundary_polygon, geom, rotation, margin) {
1010                            Ok(ifp) => ifp,
1011                            Err(_) => continue,
1012                        };
1013
1014                    if ifp.is_empty() {
1015                        continue;
1016                    }
1017
1018                    let mut nfps: Vec<Nfp> = Vec::new();
1019                    for placed in &placed_geometries {
1020                        // Use cache for NFP computation
1021                        let cache_key = (
1022                            placed.geometry.id().as_str(),
1023                            geom.id().as_str(),
1024                            rotation - placed.rotation,
1025                        );
1026
1027                        // Compute NFP at origin and cache it (with relative rotation)
1028                        // Formula: NFP_actual = translate(rotate(NFP_relative, placed.rotation), placed.position)
1029                        let nfp_at_origin = match self.nfp_cache.get_or_compute(cache_key, || {
1030                            let placed_at_origin = placed.geometry.clone();
1031                            compute_nfp(&placed_at_origin, geom, rotation - placed.rotation)
1032                        }) {
1033                            Ok(nfp) => nfp,
1034                            Err(_) => continue,
1035                        };
1036
1037                        // Transform NFP: first rotate by placed.rotation, then translate
1038                        let rotated_nfp = rotate_nfp(&nfp_at_origin, placed.rotation);
1039                        let translated_nfp = translate_nfp(&rotated_nfp, placed.position);
1040                        let expanded = self.expand_nfp(&translated_nfp, spacing);
1041                        nfps.push(expanded);
1042                    }
1043
1044                    let ifp_shrunk = self.shrink_ifp(&ifp, spacing);
1045                    let nfp_refs: Vec<&Nfp> = nfps.iter().collect();
1046
1047                    if let Some((x, y)) =
1048                        find_bottom_left_placement(&ifp_shrunk, &nfp_refs, sample_step)
1049                    {
1050                        let is_better = match best_placement {
1051                            None => true,
1052                            Some((best_x, best_y, _)) => {
1053                                x < best_x - 1e-6 || (x < best_x + 1e-6 && y < best_y - 1e-6)
1054                            }
1055                        };
1056                        if is_better {
1057                            best_placement = Some((x, y, rotation));
1058                        }
1059                    }
1060                }
1061
1062                if let Some((x, y, rotation)) = best_placement {
1063                    // Clamp to ensure geometry stays within boundary
1064                    let geom_aabb = geom.aabb_at_rotation(rotation);
1065                    let boundary_aabb = boundary.aabb();
1066
1067                    if let Some((clamped_x, clamped_y)) = clamp_placement_to_boundary_with_margin(
1068                        x,
1069                        y,
1070                        geom_aabb,
1071                        boundary_aabb,
1072                        margin,
1073                    ) {
1074                        let placement = Placement::new_2d(
1075                            geom.id().clone(),
1076                            instance,
1077                            clamped_x,
1078                            clamped_y,
1079                            rotation,
1080                        );
1081                        placements.push(placement);
1082                        placed_geometries.push(PlacedGeometry::new(
1083                            geom.clone(),
1084                            (clamped_x, clamped_y),
1085                            rotation,
1086                        ));
1087                        total_placed_area += geom.measure();
1088                        placed_count += 1;
1089
1090                        // Progress callback every piece
1091                        callback(
1092                            ProgressInfo::new()
1093                                .with_phase("NFP Placement")
1094                                .with_items(placed_count, total_pieces)
1095                                .with_utilization(total_placed_area / boundary.measure())
1096                                .with_elapsed(start.elapsed().as_millis() as u64),
1097                        );
1098                    } else {
1099                        result.unplaced.push(geom.id().clone());
1100                    }
1101                } else {
1102                    result.unplaced.push(geom.id().clone());
1103                }
1104            }
1105        }
1106
1107        result.placements = placements;
1108        result.boundaries_used = 1;
1109        result.utilization = total_placed_area / boundary.measure();
1110        result.computation_time_ms = start.elapsed().as_millis() as u64;
1111
1112        // Final progress callback
1113        callback(
1114            ProgressInfo::new()
1115                .with_phase("Complete")
1116                .with_items(placed_count, total_pieces)
1117                .with_utilization(result.utilization)
1118                .with_elapsed(result.computation_time_ms)
1119                .finished(),
1120        );
1121
1122        Ok(result)
1123    }
1124
1125    /// Solves nesting with automatic multi-strip support.
1126    ///
1127    /// When items don't fit in a single strip, automatically creates additional strips.
1128    /// Each placement's `boundary_index` indicates which strip it belongs to.
1129    /// Positions are adjusted so that strip N items have x offset of N * strip_width.
1130    pub fn solve_multi_strip(
1131        &self,
1132        geometries: &[Geometry2D],
1133        boundary: &Boundary2D,
1134    ) -> Result<SolveResult<f64>> {
1135        boundary.validate()?;
1136        self.cancelled.store(false, Ordering::Relaxed);
1137
1138        let (b_min, b_max) = boundary.aabb();
1139        let strip_width = b_max[0] - b_min[0];
1140
1141        let mut final_result = SolveResult::new();
1142        let mut remaining_geometries: Vec<Geometry2D> = geometries.to_vec();
1143        let mut strip_index = 0;
1144        let max_strips = 100; // Safety limit
1145
1146        while !remaining_geometries.is_empty() && strip_index < max_strips {
1147            if self.cancelled.load(Ordering::Relaxed) {
1148                break;
1149            }
1150
1151            // Solve on current strip
1152            let strip_result = match self.config.strategy {
1153                Strategy::BottomLeftFill => self.bottom_left_fill(&remaining_geometries, boundary),
1154                Strategy::NfpGuided => self.nfp_guided_blf(&remaining_geometries, boundary),
1155                Strategy::GeneticAlgorithm => {
1156                    self.genetic_algorithm(&remaining_geometries, boundary)
1157                }
1158                Strategy::Brkga => self.brkga(&remaining_geometries, boundary),
1159                Strategy::SimulatedAnnealing => {
1160                    self.simulated_annealing(&remaining_geometries, boundary)
1161                }
1162                Strategy::Gdrr => self.gdrr(&remaining_geometries, boundary),
1163                Strategy::Alns => self.alns(&remaining_geometries, boundary),
1164                #[cfg(feature = "milp")]
1165                Strategy::MilpExact => self.milp_exact(&remaining_geometries, boundary),
1166                #[cfg(feature = "milp")]
1167                Strategy::HybridExact => self.hybrid_exact(&remaining_geometries, boundary),
1168                _ => self.nfp_guided_blf(&remaining_geometries, boundary),
1169            }?;
1170
1171            // Validate and filter out-of-bounds placements for this strip
1172            let strip_result =
1173                validate_and_filter_placements(strip_result, &remaining_geometries, boundary);
1174
1175            if strip_result.placements.is_empty() {
1176                // No progress - items too large for strip
1177                final_result.unplaced.extend(strip_result.unplaced);
1178                break;
1179            }
1180
1181            // Collect placed geometry IDs
1182            let placed_ids: std::collections::HashSet<_> = strip_result
1183                .placements
1184                .iter()
1185                .map(|p| p.geometry_id.clone())
1186                .collect();
1187
1188            // Adjust placements for this strip and add to final result
1189            for mut placement in strip_result.placements {
1190                // Offset x position by strip_index * strip_width
1191                if !placement.position.is_empty() {
1192                    placement.position[0] += strip_index as f64 * strip_width;
1193                }
1194                placement.boundary_index = strip_index;
1195                final_result.placements.push(placement);
1196            }
1197
1198            // Update remaining geometries (those not placed)
1199            remaining_geometries.retain(|g| !placed_ids.contains(g.id()));
1200
1201            // Also handle quantity > 1: reduce quantity for partially placed items
1202            // For now, we treat each geometry independently
1203
1204            strip_index += 1;
1205        }
1206
1207        final_result.boundaries_used = strip_index;
1208        final_result.deduplicate_unplaced();
1209
1210        // Calculate overall utilization
1211        let total_boundary_area = boundary.measure() * strip_index as f64;
1212        if total_boundary_area > 0.0 {
1213            let placed_area: f64 = final_result
1214                .placements
1215                .iter()
1216                .filter_map(|p| {
1217                    geometries
1218                        .iter()
1219                        .find(|g| g.id() == &p.geometry_id)
1220                        .map(|g| {
1221                            use u_nesting_core::geometry::Geometry;
1222                            g.measure()
1223                        })
1224                })
1225                .sum();
1226            final_result.utilization = placed_area / total_boundary_area;
1227        }
1228
1229        Ok(final_result)
1230    }
1231}
1232
1233/// Computes the centroid of a polygon.
1234fn polygon_centroid(polygon: &[(f64, f64)]) -> (f64, f64) {
1235    if polygon.is_empty() {
1236        return (0.0, 0.0);
1237    }
1238
1239    let sum: (f64, f64) = polygon
1240        .iter()
1241        .fold((0.0, 0.0), |acc, &(x, y)| (acc.0 + x, acc.1 + y));
1242    let n = polygon.len() as f64;
1243    (sum.0 / n, sum.1 / n)
1244}
1245
1246impl Solver for Nester2D {
1247    type Geometry = Geometry2D;
1248    type Boundary = Boundary2D;
1249    type Scalar = f64;
1250
1251    fn solve(
1252        &self,
1253        geometries: &[Self::Geometry],
1254        boundary: &Self::Boundary,
1255    ) -> Result<SolveResult<f64>> {
1256        boundary.validate()?;
1257
1258        // Reset cancellation flag
1259        self.cancelled.store(false, Ordering::Relaxed);
1260
1261        let initial_result = match self.config.strategy {
1262            Strategy::BottomLeftFill => self.bottom_left_fill(geometries, boundary),
1263            Strategy::NfpGuided => self.nfp_guided_blf(geometries, boundary),
1264            Strategy::GeneticAlgorithm => self.genetic_algorithm(geometries, boundary),
1265            Strategy::Brkga => self.brkga(geometries, boundary),
1266            Strategy::SimulatedAnnealing => self.simulated_annealing(geometries, boundary),
1267            Strategy::Gdrr => self.gdrr(geometries, boundary),
1268            Strategy::Alns => self.alns(geometries, boundary),
1269            #[cfg(feature = "milp")]
1270            Strategy::MilpExact => self.milp_exact(geometries, boundary),
1271            #[cfg(feature = "milp")]
1272            Strategy::HybridExact => self.hybrid_exact(geometries, boundary),
1273            _ => {
1274                // Fall back to NFP-guided BLF for other strategies
1275                log::warn!(
1276                    "Strategy {:?} not yet implemented, using NfpGuided",
1277                    self.config.strategy
1278                );
1279                self.nfp_guided_blf(geometries, boundary)
1280            }
1281        }?;
1282
1283        // Validate all placements and remove any that are outside the boundary
1284        let mut result = validate_and_filter_placements(initial_result, geometries, boundary);
1285
1286        // Remove duplicate entries from unplaced list
1287        result.deduplicate_unplaced();
1288        Ok(result)
1289    }
1290
1291    fn solve_with_progress(
1292        &self,
1293        geometries: &[Self::Geometry],
1294        boundary: &Self::Boundary,
1295        callback: ProgressCallback,
1296    ) -> Result<SolveResult<f64>> {
1297        boundary.validate()?;
1298
1299        // Reset cancellation flag
1300        self.cancelled.store(false, Ordering::Relaxed);
1301
1302        let initial_result = match self.config.strategy {
1303            Strategy::BottomLeftFill => {
1304                self.bottom_left_fill_with_progress(geometries, boundary, &callback)?
1305            }
1306            Strategy::NfpGuided => {
1307                self.nfp_guided_blf_with_progress(geometries, boundary, &callback)?
1308            }
1309            Strategy::GeneticAlgorithm => {
1310                let mut ga_config = GaConfig::default()
1311                    .with_population_size(self.config.population_size)
1312                    .with_max_generations(self.config.max_generations)
1313                    .with_crossover_rate(self.config.crossover_rate)
1314                    .with_mutation_rate(self.config.mutation_rate);
1315
1316                // Apply time limit if specified
1317                if self.config.time_limit_ms > 0 {
1318                    ga_config = ga_config.with_time_limit(std::time::Duration::from_millis(
1319                        self.config.time_limit_ms,
1320                    ));
1321                }
1322
1323                run_ga_nesting_with_progress(
1324                    geometries,
1325                    boundary,
1326                    &self.config,
1327                    ga_config,
1328                    self.cancelled.clone(),
1329                    callback,
1330                )
1331            }
1332            // For other strategies, use basic progress reporting
1333            _ => {
1334                log::warn!(
1335                    "Strategy {:?} not yet implemented, using NfpGuided",
1336                    self.config.strategy
1337                );
1338                self.nfp_guided_blf_with_progress(geometries, boundary, &callback)?
1339            }
1340        };
1341
1342        // Validate all placements and remove any that are outside the boundary
1343        let mut result = validate_and_filter_placements(initial_result, geometries, boundary);
1344
1345        // Remove duplicate entries from unplaced list
1346        result.deduplicate_unplaced();
1347        Ok(result)
1348    }
1349
1350    fn cancel(&self) {
1351        self.cancelled.store(true, Ordering::Relaxed);
1352    }
1353}
1354
1355#[cfg(test)]
1356mod tests {
1357    use super::*;
1358
1359    #[test]
1360    fn test_simple_nesting() {
1361        let geometries = vec![
1362            Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(3),
1363            Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
1364        ];
1365
1366        let boundary = Boundary2D::rectangle(100.0, 50.0);
1367        let nester = Nester2D::default_config();
1368
1369        let result = nester.solve(&geometries, &boundary).unwrap();
1370
1371        assert!(result.utilization > 0.0);
1372        assert!(result.placements.len() <= 5); // 3 + 2 = 5 pieces
1373    }
1374
1375    #[test]
1376    fn test_placement_within_bounds() {
1377        let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
1378
1379        let boundary = Boundary2D::rectangle(50.0, 50.0);
1380        let config = Config::default().with_margin(5.0).with_spacing(2.0);
1381        let nester = Nester2D::new(config);
1382
1383        let result = nester.solve(&geometries, &boundary).unwrap();
1384
1385        // All pieces should be placed
1386        assert_eq!(result.placements.len(), 4);
1387        assert!(result.unplaced.is_empty());
1388
1389        // Verify placements are within bounds (with margin)
1390        for p in &result.placements {
1391            assert!(p.position[0] >= 5.0);
1392            assert!(p.position[1] >= 5.0);
1393        }
1394    }
1395
1396    #[test]
1397    fn test_nfp_guided_basic() {
1398        let geometries = vec![
1399            Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
1400            Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(1),
1401        ];
1402
1403        let boundary = Boundary2D::rectangle(100.0, 50.0);
1404        let config = Config::default().with_strategy(Strategy::NfpGuided);
1405        let nester = Nester2D::new(config);
1406
1407        let result = nester.solve(&geometries, &boundary).unwrap();
1408
1409        assert!(result.utilization > 0.0);
1410        assert_eq!(result.placements.len(), 3); // 2 + 1 = 3 pieces
1411        assert!(result.unplaced.is_empty());
1412    }
1413
1414    #[test]
1415    fn test_nfp_guided_with_spacing() {
1416        let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
1417
1418        let boundary = Boundary2D::rectangle(50.0, 50.0);
1419        let config = Config::default()
1420            .with_strategy(Strategy::NfpGuided)
1421            .with_margin(2.0)
1422            .with_spacing(3.0);
1423        let nester = Nester2D::new(config);
1424
1425        let result = nester.solve(&geometries, &boundary).unwrap();
1426
1427        // All pieces should be placed
1428        assert_eq!(result.placements.len(), 4);
1429        assert!(result.unplaced.is_empty());
1430
1431        // Utilization should be positive
1432        assert!(result.utilization > 0.0);
1433    }
1434
1435    #[test]
1436    fn test_nfp_guided_no_overlap() {
1437        let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(3)];
1438
1439        let boundary = Boundary2D::rectangle(100.0, 100.0);
1440        let config = Config::default().with_strategy(Strategy::NfpGuided);
1441        let nester = Nester2D::new(config);
1442
1443        let result = nester.solve(&geometries, &boundary).unwrap();
1444
1445        assert_eq!(result.placements.len(), 3);
1446
1447        // Verify no overlaps between placements
1448        for i in 0..result.placements.len() {
1449            for j in (i + 1)..result.placements.len() {
1450                let p1 = &result.placements[i];
1451                let p2 = &result.placements[j];
1452
1453                // Simple AABB overlap check for rectangles
1454                let r1_min_x = p1.position[0];
1455                let r1_max_x = p1.position[0] + 20.0;
1456                let r1_min_y = p1.position[1];
1457                let r1_max_y = p1.position[1] + 20.0;
1458
1459                let r2_min_x = p2.position[0];
1460                let r2_max_x = p2.position[0] + 20.0;
1461                let r2_min_y = p2.position[1];
1462                let r2_max_y = p2.position[1] + 20.0;
1463
1464                // Check no overlap (with small tolerance for floating point)
1465                let overlaps_x = r1_min_x < r2_max_x - 0.01 && r1_max_x > r2_min_x + 0.01;
1466                let overlaps_y = r1_min_y < r2_max_y - 0.01 && r1_max_y > r2_min_y + 0.01;
1467
1468                assert!(
1469                    !(overlaps_x && overlaps_y),
1470                    "Placements {} and {} overlap",
1471                    i,
1472                    j
1473                );
1474            }
1475        }
1476    }
1477
1478    #[test]
1479    fn test_nfp_guided_utilization() {
1480        // Perfect fit: 4 rectangles of 25x25 in a 100x50 boundary
1481        let geometries = vec![Geometry2D::rectangle("R1", 25.0, 25.0).with_quantity(4)];
1482
1483        let boundary = Boundary2D::rectangle(100.0, 50.0);
1484        let config = Config::default().with_strategy(Strategy::NfpGuided);
1485        let nester = Nester2D::new(config);
1486
1487        let result = nester.solve(&geometries, &boundary).unwrap();
1488
1489        // All pieces should be placed
1490        assert_eq!(result.placements.len(), 4);
1491
1492        // Utilization should be 50% (4 * 625 = 2500 / 5000)
1493        assert!(result.utilization > 0.45);
1494    }
1495
1496    #[test]
1497    fn test_polygon_centroid() {
1498        // Test the centroid calculation
1499        let square = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
1500        let (cx, cy) = polygon_centroid(&square);
1501        assert!((cx - 5.0).abs() < 0.01);
1502        assert!((cy - 5.0).abs() < 0.01);
1503
1504        let triangle = vec![(0.0, 0.0), (6.0, 0.0), (3.0, 6.0)];
1505        let (cx, cy) = polygon_centroid(&triangle);
1506        assert!((cx - 3.0).abs() < 0.01);
1507        assert!((cy - 2.0).abs() < 0.01);
1508    }
1509
1510    #[test]
1511    fn test_ga_strategy_basic() {
1512        let geometries = vec![
1513            Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
1514            Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
1515        ];
1516
1517        let boundary = Boundary2D::rectangle(100.0, 50.0);
1518        let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
1519        let nester = Nester2D::new(config);
1520
1521        let result = nester.solve(&geometries, &boundary).unwrap();
1522
1523        assert!(result.utilization > 0.0);
1524        assert!(!result.placements.is_empty());
1525        // GA should report generations and fitness
1526        assert!(result.generations.is_some());
1527        assert!(result.best_fitness.is_some());
1528        assert!(result.strategy == Some("GeneticAlgorithm".to_string()));
1529    }
1530
1531    #[test]
1532    fn test_ga_strategy_all_placed() {
1533        // Easy case: 4 small rectangles in large boundary
1534        let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
1535
1536        let boundary = Boundary2D::rectangle(100.0, 100.0);
1537        let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
1538        let nester = Nester2D::new(config);
1539
1540        let result = nester.solve(&geometries, &boundary).unwrap();
1541
1542        // All 4 pieces should fit
1543        assert_eq!(result.placements.len(), 4);
1544        assert!(result.unplaced.is_empty());
1545    }
1546
1547    #[test]
1548    fn test_brkga_strategy_basic() {
1549        let geometries = vec![
1550            Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
1551            Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
1552        ];
1553
1554        let boundary = Boundary2D::rectangle(100.0, 50.0);
1555        let config = Config::default().with_strategy(Strategy::Brkga);
1556        let nester = Nester2D::new(config);
1557
1558        let result = nester.solve(&geometries, &boundary).unwrap();
1559
1560        assert!(result.utilization > 0.0);
1561        assert!(!result.placements.is_empty());
1562        // BRKGA should report generations and fitness
1563        assert!(result.generations.is_some());
1564        assert!(result.best_fitness.is_some());
1565        assert!(result.strategy == Some("BRKGA".to_string()));
1566    }
1567
1568    #[test]
1569    fn test_brkga_strategy_all_placed() {
1570        // Easy case: 4 small rectangles in large boundary
1571        let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
1572
1573        let boundary = Boundary2D::rectangle(100.0, 100.0);
1574        // Use longer time limit to ensure BRKGA converges on all platforms
1575        let config = Config::default()
1576            .with_strategy(Strategy::Brkga)
1577            .with_time_limit(30000); // 30 seconds
1578        let nester = Nester2D::new(config);
1579
1580        let result = nester.solve(&geometries, &boundary).unwrap();
1581
1582        // BRKGA is stochastic; expect at least 3 of 4 pieces placed
1583        // (4 x 20x20 = 1600 area in 10000 boundary = 16% utilization, easy case)
1584        assert!(
1585            result.placements.len() >= 3,
1586            "Expected at least 3 placements, got {}",
1587            result.placements.len()
1588        );
1589    }
1590
1591    #[test]
1592    fn test_gdrr_strategy_basic() {
1593        let geometries = vec![
1594            Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
1595            Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
1596        ];
1597
1598        let boundary = Boundary2D::rectangle(100.0, 50.0);
1599        let config = Config::default().with_strategy(Strategy::Gdrr);
1600        let nester = Nester2D::new(config);
1601
1602        let result = nester.solve(&geometries, &boundary).unwrap();
1603
1604        assert!(result.utilization > 0.0);
1605        assert!(!result.placements.is_empty());
1606        // GDRR should report iterations and fitness
1607        assert!(result.iterations.is_some());
1608        assert!(result.best_fitness.is_some());
1609        assert!(result.strategy == Some("GDRR".to_string()));
1610    }
1611
1612    #[test]
1613    fn test_gdrr_strategy_all_placed() {
1614        // Easy case: 4 small rectangles in large boundary
1615        let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
1616
1617        let boundary = Boundary2D::rectangle(100.0, 100.0);
1618        let config = Config::default().with_strategy(Strategy::Gdrr);
1619        let nester = Nester2D::new(config);
1620
1621        let result = nester.solve(&geometries, &boundary).unwrap();
1622
1623        // All 4 pieces should fit
1624        assert_eq!(result.placements.len(), 4);
1625        assert!(result.unplaced.is_empty());
1626    }
1627
1628    #[test]
1629    fn test_alns_strategy_basic() {
1630        let geometries = vec![
1631            Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
1632            Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
1633        ];
1634
1635        let boundary = Boundary2D::rectangle(100.0, 50.0);
1636        let config = Config::default().with_strategy(Strategy::Alns);
1637        let nester = Nester2D::new(config);
1638
1639        let result = nester.solve(&geometries, &boundary).unwrap();
1640
1641        assert!(result.utilization > 0.0);
1642        assert!(!result.placements.is_empty());
1643        // ALNS should report iterations and fitness
1644        assert!(result.iterations.is_some());
1645        assert!(result.best_fitness.is_some());
1646        assert!(result.strategy == Some("ALNS".to_string()));
1647    }
1648
1649    #[test]
1650    fn test_alns_strategy_all_placed() {
1651        // Easy case: 4 small rectangles in large boundary
1652        let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
1653
1654        let boundary = Boundary2D::rectangle(100.0, 100.0);
1655        let config = Config::default().with_strategy(Strategy::Alns);
1656        let nester = Nester2D::new(config);
1657
1658        let result = nester.solve(&geometries, &boundary).unwrap();
1659
1660        // All 4 pieces should fit
1661        assert_eq!(result.placements.len(), 4);
1662        assert!(result.unplaced.is_empty());
1663    }
1664
1665    #[test]
1666    fn test_blf_rotation_optimization() {
1667        // Test that BLF uses rotation to optimize placement
1668        // A 30x10 rectangle can fit better in a narrow strip when rotated 90 degrees
1669        let geometries = vec![Geometry2D::rectangle("R1", 30.0, 10.0)
1670                .with_rotations(vec![0.0, std::f64::consts::FRAC_PI_2]) // 0 and 90 degrees
1671                .with_quantity(3)];
1672
1673        // Strip that's 35 wide: 30x10 won't fit two side-by-side at 0 deg
1674        // But two 10x30 (rotated 90 deg) can fit vertically in 95 height
1675        let boundary = Boundary2D::rectangle(35.0, 95.0);
1676        let nester = Nester2D::default_config();
1677
1678        let result = nester.solve(&geometries, &boundary).unwrap();
1679
1680        // All 3 pieces should be placed (by rotating)
1681        assert_eq!(
1682            result.placements.len(),
1683            3,
1684            "All pieces should be placed with rotation optimization"
1685        );
1686        assert!(result.unplaced.is_empty());
1687    }
1688
1689    #[test]
1690    fn test_blf_selects_best_rotation() {
1691        // Verify BLF selects optimal rotation, not just the first one
1692        let geometries = vec![Geometry2D::rectangle("R1", 40.0, 10.0)
1693                .with_rotations(vec![0.0, std::f64::consts::FRAC_PI_2]) // 0 and 90 degrees
1694                .with_quantity(2)];
1695
1696        // In a 45x50 boundary:
1697        // - At 0 deg: 40x10, only one fits horizontally (40 < 45), next row needed
1698        // - At 90 deg: 10x40, two can fit side-by-side (10+10 < 45) in one row
1699        let boundary = Boundary2D::rectangle(45.0, 50.0);
1700        let nester = Nester2D::default_config();
1701
1702        let result = nester.solve(&geometries, &boundary).unwrap();
1703
1704        assert_eq!(result.placements.len(), 2);
1705        assert!(result.unplaced.is_empty());
1706    }
1707
1708    #[test]
1709    fn test_progress_callback_blf() {
1710        use std::sync::atomic::{AtomicUsize, Ordering};
1711        use std::sync::Arc;
1712
1713        let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
1714        let boundary = Boundary2D::rectangle(50.0, 50.0);
1715        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
1716        let nester = Nester2D::new(config);
1717
1718        let callback_count = Arc::new(AtomicUsize::new(0));
1719        let callback_count_clone = callback_count.clone();
1720        let last_items_placed = Arc::new(AtomicUsize::new(0));
1721        let last_items_placed_clone = last_items_placed.clone();
1722
1723        let callback: ProgressCallback = Box::new(move |info| {
1724            callback_count_clone.fetch_add(1, Ordering::Relaxed);
1725            last_items_placed_clone.store(info.items_placed, Ordering::Relaxed);
1726        });
1727
1728        let result = nester
1729            .solve_with_progress(&geometries, &boundary, callback)
1730            .unwrap();
1731
1732        // Verify callback was called (at least once per piece + initial + final)
1733        let count = callback_count.load(Ordering::Relaxed);
1734        assert!(
1735            count >= 5,
1736            "Expected at least 5 callbacks (1 initial + 4 pieces + 1 final), got {}",
1737            count
1738        );
1739
1740        // Verify final items_placed
1741        let final_placed = last_items_placed.load(Ordering::Relaxed);
1742        assert_eq!(final_placed, 4, "Should report 4 items placed");
1743
1744        // Verify result
1745        assert_eq!(result.placements.len(), 4);
1746    }
1747
1748    #[test]
1749    fn test_progress_callback_nfp() {
1750        use std::sync::atomic::{AtomicUsize, Ordering};
1751        use std::sync::Arc;
1752
1753        let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(2)];
1754        let boundary = Boundary2D::rectangle(50.0, 50.0);
1755        let config = Config::default().with_strategy(Strategy::NfpGuided);
1756        let nester = Nester2D::new(config);
1757
1758        let callback_count = Arc::new(AtomicUsize::new(0));
1759        let callback_count_clone = callback_count.clone();
1760
1761        let callback: ProgressCallback = Box::new(move |info| {
1762            callback_count_clone.fetch_add(1, Ordering::Relaxed);
1763            assert!(info.items_placed <= info.total_items);
1764        });
1765
1766        let result = nester
1767            .solve_with_progress(&geometries, &boundary, callback)
1768            .unwrap();
1769
1770        // Verify callback was called
1771        let count = callback_count.load(Ordering::Relaxed);
1772        assert!(count >= 3, "Expected at least 3 callbacks, got {}", count);
1773
1774        // Verify result
1775        assert_eq!(result.placements.len(), 2);
1776    }
1777
1778    #[test]
1779    fn test_time_limit_honored() {
1780        // Create many geometries to ensure BLF takes measurable time
1781        let geometries: Vec<Geometry2D> = (0..100)
1782            .map(|i| Geometry2D::rectangle(&format!("R{}", i), 5.0, 5.0))
1783            .collect();
1784        let boundary = Boundary2D::rectangle(1000.0, 1000.0);
1785
1786        // Set a very short time limit (1ms) to ensure timeout
1787        let config = Config::default()
1788            .with_strategy(Strategy::BottomLeftFill)
1789            .with_time_limit(1);
1790        let nester = Nester2D::new(config);
1791
1792        let result = nester.solve(&geometries, &boundary).unwrap();
1793
1794        // With such a short time limit, we may not place all items
1795        // The test verifies that the solver respects the time limit
1796        assert!(
1797            result.computation_time_ms <= 100, // Allow some margin for overhead
1798            "Computation took too long: {}ms (expected <= 100ms with 1ms limit)",
1799            result.computation_time_ms
1800        );
1801    }
1802
1803    #[test]
1804    fn test_time_limit_zero_unlimited() {
1805        // time_limit_ms = 0 means unlimited
1806        let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
1807        let boundary = Boundary2D::rectangle(50.0, 50.0);
1808
1809        let config = Config::default()
1810            .with_strategy(Strategy::BottomLeftFill)
1811            .with_time_limit(0); // Unlimited
1812        let nester = Nester2D::new(config);
1813
1814        let result = nester.solve(&geometries, &boundary).unwrap();
1815
1816        // Should place all items (no early exit)
1817        assert_eq!(result.placements.len(), 4);
1818    }
1819
1820    #[test]
1821    fn test_blf_bounds_clamping() {
1822        // Test that BLF correctly clamps placements within boundary
1823        // Create a shape with non-zero g_min (similar to Gear shape)
1824        // Gear-like: x ranges from 5 to 95 (width=90), y from 5 to 95 (height=90)
1825        let gear_like = Geometry2D::new("gear")
1826            .with_polygon(vec![
1827                (50.0, 5.0), // Bottom
1828                (65.0, 15.0),
1829                (77.0, 18.0),
1830                (80.0, 32.0),
1831                (95.0, 50.0), // Right
1832                (80.0, 68.0),
1833                (77.0, 82.0),
1834                (65.0, 85.0),
1835                (50.0, 95.0), // Top
1836                (35.0, 85.0),
1837                (23.0, 82.0),
1838                (20.0, 68.0),
1839                (5.0, 50.0), // Left (min_x = 5)
1840                (20.0, 32.0),
1841                (23.0, 18.0),
1842                (35.0, 15.0),
1843            ])
1844            .with_quantity(1);
1845
1846        // Boundary is 100x100
1847        let boundary = Boundary2D::rectangle(100.0, 100.0);
1848
1849        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
1850        let nester = Nester2D::new(config);
1851
1852        let result = nester.solve(&[gear_like.clone()], &boundary).unwrap();
1853
1854        assert_eq!(result.placements.len(), 1);
1855        let placement = &result.placements[0];
1856
1857        // Origin position
1858        let origin_x = placement.position[0];
1859        let origin_y = placement.position[1];
1860
1861        // Get rotation from placement (2D rotation is a single value in Vec)
1862        let rotation = placement.rotation.first().copied().unwrap_or(0.0);
1863
1864        // Get AABB at rotation
1865        let (g_min, g_max) = gear_like.aabb_at_rotation(rotation);
1866
1867        // Actual geometry bounds after placement
1868        let actual_min_x = origin_x + g_min[0];
1869        let actual_max_x = origin_x + g_max[0];
1870        let actual_min_y = origin_y + g_min[1];
1871        let actual_max_y = origin_y + g_max[1];
1872
1873        // All edges should be within boundary [0, 100]
1874        assert!(
1875            actual_min_x >= 0.0,
1876            "Left edge {} should be >= 0",
1877            actual_min_x
1878        );
1879        assert!(
1880            actual_max_x <= 100.0,
1881            "Right edge {} should be <= 100",
1882            actual_max_x
1883        );
1884        assert!(
1885            actual_min_y >= 0.0,
1886            "Bottom edge {} should be >= 0",
1887            actual_min_y
1888        );
1889        assert!(
1890            actual_max_y <= 100.0,
1891            "Top edge {} should be <= 100",
1892            actual_max_y
1893        );
1894    }
1895
1896    #[test]
1897    fn test_blf_bounds_clamping_many_pieces() {
1898        // Test BLF bounds clamping with many pieces to trigger row overflow
1899        // This mimics the actual failing case from test_blf.py
1900        let gear_like = Geometry2D::new("gear")
1901            .with_polygon(vec![
1902                (50.0, 5.0),
1903                (65.0, 15.0),
1904                (77.0, 18.0),
1905                (80.0, 32.0),
1906                (95.0, 50.0),
1907                (80.0, 68.0),
1908                (77.0, 82.0),
1909                (65.0, 85.0),
1910                (50.0, 95.0),
1911                (35.0, 85.0),
1912                (23.0, 82.0),
1913                (20.0, 68.0),
1914                (5.0, 50.0),
1915                (20.0, 32.0),
1916                (23.0, 18.0),
1917                (35.0, 15.0),
1918            ])
1919            .with_quantity(13); // Same as Gear (shape 8) in test_blf.py
1920
1921        // Boundary is 500x500 like the test
1922        let boundary = Boundary2D::rectangle(500.0, 500.0);
1923
1924        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
1925        let nester = Nester2D::new(config);
1926
1927        let result = nester.solve(&[gear_like.clone()], &boundary).unwrap();
1928
1929        // Check that ALL placements are within bounds
1930        for (i, placement) in result.placements.iter().enumerate() {
1931            let origin_x = placement.position[0];
1932            let origin_y = placement.position[1];
1933            let rotation = placement.rotation.first().copied().unwrap_or(0.0);
1934
1935            let (g_min, g_max) = gear_like.aabb_at_rotation(rotation);
1936
1937            let actual_min_x = origin_x + g_min[0];
1938            let actual_max_x = origin_x + g_max[0];
1939            let actual_min_y = origin_y + g_min[1];
1940            let actual_max_y = origin_y + g_max[1];
1941
1942            assert!(
1943                actual_min_x >= -0.01,
1944                "Piece {}: Left edge {} should be >= 0",
1945                i,
1946                actual_min_x
1947            );
1948            assert!(
1949                actual_max_x <= 500.01,
1950                "Piece {}: Right edge {} should be <= 500",
1951                i,
1952                actual_max_x
1953            );
1954            assert!(
1955                actual_min_y >= -0.01,
1956                "Piece {}: Bottom edge {} should be >= 0",
1957                i,
1958                actual_min_y
1959            );
1960            assert!(
1961                actual_max_y <= 500.01,
1962                "Piece {}: Top edge {} should be <= 500",
1963                i,
1964                actual_max_y
1965            );
1966        }
1967    }
1968
1969    #[test]
1970    fn test_blf_bounds_trace() {
1971        // Debug test: trace through BLF to understand why clamping doesn't work
1972        let gear = Geometry2D::new("gear").with_polygon(vec![
1973            (50.0, 5.0),
1974            (65.0, 15.0),
1975            (77.0, 18.0),
1976            (80.0, 32.0),
1977            (95.0, 50.0),
1978            (80.0, 68.0),
1979            (77.0, 82.0),
1980            (65.0, 85.0),
1981            (50.0, 95.0),
1982            (35.0, 85.0),
1983            (23.0, 82.0),
1984            (20.0, 68.0),
1985            (5.0, 50.0),
1986            (20.0, 32.0),
1987            (23.0, 18.0),
1988            (35.0, 15.0),
1989        ]);
1990
1991        // Verify AABB
1992        let (g_min, g_max) = gear.aabb();
1993        println!("Gear AABB: min={:?}, max={:?}", g_min, g_max);
1994        assert!(
1995            (g_min[0] - 5.0).abs() < 0.01,
1996            "g_min[0] should be 5, got {}",
1997            g_min[0]
1998        );
1999        assert!(
2000            (g_max[0] - 95.0).abs() < 0.01,
2001            "g_max[0] should be 95, got {}",
2002            g_max[0]
2003        );
2004
2005        // Verify valid origin range for 500x500 boundary
2006        let b_max_x = 500.0;
2007        let margin = 0.0;
2008        let max_valid_x = b_max_x - margin - g_max[0];
2009        println!(
2010            "max_valid_x = {} - {} - {} = {}",
2011            b_max_x, margin, g_max[0], max_valid_x
2012        );
2013        assert!(
2014            (max_valid_x - 405.0).abs() < 0.01,
2015            "max_valid_x should be 405, got {}",
2016            max_valid_x
2017        );
2018
2019        // Run BLF and check the result
2020        let boundary = Boundary2D::rectangle(500.0, 500.0);
2021        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2022        let nester = Nester2D::new(config);
2023
2024        let result = nester
2025            .solve(&[gear.clone().with_quantity(1)], &boundary)
2026            .unwrap();
2027
2028        assert_eq!(result.placements.len(), 1);
2029        let p = &result.placements[0];
2030        let origin_x = p.position[0];
2031        let rotation = p.rotation.first().copied().unwrap_or(0.0);
2032
2033        let (g_min_r, g_max_r) = gear.aabb_at_rotation(rotation);
2034        let actual_max_x = origin_x + g_max_r[0];
2035
2036        println!("Placement: origin_x={}, rotation={}", origin_x, rotation);
2037        println!(
2038            "At rotation {}: g_min={:?}, g_max={:?}",
2039            rotation, g_min_r, g_max_r
2040        );
2041        println!(
2042            "Actual max x: {} + {} = {}",
2043            origin_x, g_max_r[0], actual_max_x
2044        );
2045
2046        assert!(
2047            actual_max_x <= 500.01,
2048            "Geometry exceeds boundary: max_x={} > 500",
2049            actual_max_x
2050        );
2051    }
2052
2053    #[test]
2054    fn test_blf_bounds_many_pieces_direct() {
2055        // Test with many pieces to trigger the boundary violation
2056        let gear = Geometry2D::new("gear")
2057            .with_polygon(vec![
2058                (50.0, 5.0),
2059                (65.0, 15.0),
2060                (77.0, 18.0),
2061                (80.0, 32.0),
2062                (95.0, 50.0),
2063                (80.0, 68.0),
2064                (77.0, 82.0),
2065                (65.0, 85.0),
2066                (50.0, 95.0),
2067                (35.0, 85.0),
2068                (23.0, 82.0),
2069                (20.0, 68.0),
2070                (5.0, 50.0),
2071                (20.0, 32.0),
2072                (23.0, 18.0),
2073                (35.0, 15.0),
2074            ])
2075            .with_quantity(25); // Many pieces
2076
2077        let boundary = Boundary2D::rectangle(500.0, 500.0);
2078        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2079        let nester = Nester2D::new(config);
2080
2081        let result = nester.solve(&[gear.clone()], &boundary).unwrap();
2082
2083        println!("Placed {} pieces", result.placements.len());
2084
2085        // Check all placements
2086        for (i, p) in result.placements.iter().enumerate() {
2087            let origin_x = p.position[0];
2088            let origin_y = p.position[1];
2089            let rotation = p.rotation.first().copied().unwrap_or(0.0);
2090
2091            let (g_min_r, g_max_r) = gear.aabb_at_rotation(rotation);
2092
2093            let actual_min_x = origin_x + g_min_r[0];
2094            let actual_max_x = origin_x + g_max_r[0];
2095            let actual_min_y = origin_y + g_min_r[1];
2096            let actual_max_y = origin_y + g_max_r[1];
2097
2098            println!(
2099                "Piece {}: origin=({:.1}, {:.1}), rot={:.2}, bounds=[{:.1},{:.1}]x[{:.1},{:.1}]",
2100                i,
2101                origin_x,
2102                origin_y,
2103                rotation,
2104                actual_min_x,
2105                actual_max_x,
2106                actual_min_y,
2107                actual_max_y
2108            );
2109
2110            assert!(
2111                actual_max_x <= 500.01,
2112                "Piece {}: Right edge {} > 500",
2113                i,
2114                actual_max_x
2115            );
2116            assert!(
2117                actual_max_y <= 500.01,
2118                "Piece {}: Top edge {} > 500",
2119                i,
2120                actual_max_y
2121            );
2122        }
2123    }
2124
2125    #[test]
2126    fn test_blf_bounds_multi_strip() {
2127        // Test with solve_multi_strip which is what benchmark runner uses
2128        let gear = Geometry2D::new("gear")
2129            .with_polygon(vec![
2130                (50.0, 5.0),
2131                (65.0, 15.0),
2132                (77.0, 18.0),
2133                (80.0, 32.0),
2134                (95.0, 50.0),
2135                (80.0, 68.0),
2136                (77.0, 82.0),
2137                (65.0, 85.0),
2138                (50.0, 95.0),
2139                (35.0, 85.0),
2140                (23.0, 82.0),
2141                (20.0, 68.0),
2142                (5.0, 50.0),
2143                (20.0, 32.0),
2144                (23.0, 18.0),
2145                (35.0, 15.0),
2146            ])
2147            .with_quantity(50); // Many pieces to force multiple strips
2148
2149        let boundary = Boundary2D::rectangle(500.0, 500.0);
2150        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2151        let nester = Nester2D::new(config);
2152
2153        // Use solve_multi_strip like benchmark runner does
2154        let result = nester
2155            .solve_multi_strip(&[gear.clone()], &boundary)
2156            .unwrap();
2157
2158        println!(
2159            "Placed {} pieces across {} strips",
2160            result.placements.len(),
2161            result.boundaries_used
2162        );
2163
2164        // Check all placements - within their respective strips
2165        let strip_width = 500.0;
2166        for (i, p) in result.placements.iter().enumerate() {
2167            let origin_x = p.position[0];
2168            let origin_y = p.position[1];
2169            let rotation = p.rotation.first().copied().unwrap_or(0.0);
2170            let strip_idx = p.boundary_index;
2171
2172            // Calculate local position within strip
2173            let local_x = origin_x - (strip_idx as f64 * strip_width);
2174
2175            let (_g_min_r, g_max_r) = gear.aabb_at_rotation(rotation);
2176
2177            let local_max_x = local_x + g_max_r[0];
2178            let local_max_y = origin_y + g_max_r[1];
2179
2180            println!(
2181                "Piece {}: strip={}, origin=({:.1}, {:.1}), local_x={:.1}, rot={:.2}, local_max_x={:.1}",
2182                i, strip_idx, origin_x, origin_y, local_x, rotation, local_max_x
2183            );
2184
2185            assert!(
2186                local_max_x <= 500.01,
2187                "Piece {}: In strip {}, local right edge {:.1} > 500",
2188                i,
2189                strip_idx,
2190                local_max_x
2191            );
2192            assert!(
2193                local_max_y <= 500.01,
2194                "Piece {}: Top edge {:.1} > 500",
2195                i,
2196                local_max_y
2197            );
2198        }
2199    }
2200
2201    #[test]
2202    fn test_blf_bounds_mixed_shapes() {
2203        // Replicate test_blf.py with all 9 shapes
2204        let shapes = vec![
2205            // Shape 0: Rounded rectangle (demand 2)
2206            Geometry2D::new("shape0")
2207                .with_polygon(vec![
2208                    (0.0, 0.0),
2209                    (180.0, 0.0),
2210                    (195.0, 15.0),
2211                    (200.0, 50.0),
2212                    (200.0, 150.0),
2213                    (195.0, 185.0),
2214                    (180.0, 200.0),
2215                    (20.0, 200.0),
2216                    (5.0, 185.0),
2217                    (0.0, 150.0),
2218                    (0.0, 50.0),
2219                    (5.0, 15.0),
2220                ])
2221                .with_quantity(2),
2222            // Shape 1: Circular-ish (demand 4)
2223            Geometry2D::new("shape1")
2224                .with_polygon(vec![
2225                    (60.0, 0.0),
2226                    (85.0, 7.0),
2227                    (104.0, 25.0),
2228                    (118.0, 50.0),
2229                    (120.0, 60.0),
2230                    (118.0, 70.0),
2231                    (104.0, 95.0),
2232                    (85.0, 113.0),
2233                    (60.0, 120.0),
2234                    (35.0, 113.0),
2235                    (16.0, 95.0),
2236                    (2.0, 70.0),
2237                    (0.0, 60.0),
2238                    (2.0, 50.0),
2239                    (16.0, 25.0),
2240                    (35.0, 7.0),
2241                ])
2242                .with_quantity(4),
2243            // Shape 2: L-shape (demand 6)
2244            Geometry2D::new("shape2")
2245                .with_polygon(vec![
2246                    (0.0, 0.0),
2247                    (80.0, 0.0),
2248                    (80.0, 20.0),
2249                    (20.0, 20.0),
2250                    (20.0, 80.0),
2251                    (0.0, 80.0),
2252                ])
2253                .with_quantity(6),
2254            // Shape 3: Triangle (demand 6)
2255            Geometry2D::new("shape3")
2256                .with_polygon(vec![(0.0, 0.0), (70.0, 0.0), (0.0, 70.0)])
2257                .with_quantity(6),
2258            // Shape 4: Rectangle (demand 4)
2259            Geometry2D::new("shape4")
2260                .with_polygon(vec![(0.0, 0.0), (120.0, 0.0), (120.0, 60.0), (0.0, 60.0)])
2261                .with_quantity(4),
2262            // Shape 5: Hexagon (demand 8)
2263            Geometry2D::new("shape5")
2264                .with_polygon(vec![
2265                    (15.0, 0.0),
2266                    (45.0, 0.0),
2267                    (60.0, 26.0),
2268                    (45.0, 52.0),
2269                    (15.0, 52.0),
2270                    (0.0, 26.0),
2271                ])
2272                .with_quantity(8),
2273            // Shape 6: T-shape (demand 4)
2274            Geometry2D::new("shape6")
2275                .with_polygon(vec![
2276                    (0.0, 0.0),
2277                    (90.0, 0.0),
2278                    (90.0, 12.0),
2279                    (55.0, 12.0),
2280                    (55.0, 60.0),
2281                    (35.0, 60.0),
2282                    (35.0, 12.0),
2283                    (0.0, 12.0),
2284                ])
2285                .with_quantity(4),
2286            // Shape 7: Rounded square (demand 3)
2287            Geometry2D::new("shape7")
2288                .with_polygon(vec![
2289                    (0.0, 10.0),
2290                    (10.0, 0.0),
2291                    (70.0, 0.0),
2292                    (80.0, 10.0),
2293                    (80.0, 70.0),
2294                    (70.0, 80.0),
2295                    (10.0, 80.0),
2296                    (0.0, 70.0),
2297                ])
2298                .with_quantity(3),
2299            // Shape 8: Gear (demand 13) - the problematic shape
2300            Geometry2D::new("shape8_gear")
2301                .with_polygon(vec![
2302                    (50.0, 5.0),
2303                    (65.0, 15.0),
2304                    (77.0, 18.0),
2305                    (80.0, 32.0),
2306                    (95.0, 50.0),
2307                    (80.0, 68.0),
2308                    (77.0, 82.0),
2309                    (65.0, 85.0),
2310                    (50.0, 95.0),
2311                    (35.0, 85.0),
2312                    (23.0, 82.0),
2313                    (20.0, 68.0),
2314                    (5.0, 50.0),
2315                    (20.0, 32.0),
2316                    (23.0, 18.0),
2317                    (35.0, 15.0),
2318                ])
2319                .with_quantity(13),
2320        ];
2321
2322        // Total: 2+4+6+6+4+8+4+3+13 = 50 pieces
2323        let boundary = Boundary2D::rectangle(500.0, 500.0);
2324        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2325        let nester = Nester2D::new(config);
2326
2327        let result = nester.solve_multi_strip(&shapes, &boundary).unwrap();
2328
2329        println!(
2330            "Placed {} pieces across {} strips",
2331            result.placements.len(),
2332            result.boundaries_used
2333        );
2334
2335        // Check placements for Gear (shape8) specifically
2336        let strip_width = 500.0;
2337        let gear_aabb = shapes[8].aabb();
2338        println!("Gear AABB: min={:?}, max={:?}", gear_aabb.0, gear_aabb.1);
2339
2340        let mut violations = Vec::new();
2341        for p in &result.placements {
2342            if p.geometry_id.as_str().starts_with("shape8") {
2343                let origin_x = p.position[0];
2344                let _origin_y = p.position[1];
2345                let rotation = p.rotation.first().copied().unwrap_or(0.0);
2346                let strip_idx = p.boundary_index;
2347                let local_x = origin_x - (strip_idx as f64 * strip_width);
2348
2349                let (_g_min_r, g_max_r) = shapes[8].aabb_at_rotation(rotation);
2350                let local_max_x = local_x + g_max_r[0];
2351
2352                println!(
2353                    "{}: strip={}, local_x={:.1}, rot={:.2}, local_max_x={:.1}",
2354                    p.geometry_id, strip_idx, local_x, rotation, local_max_x
2355                );
2356
2357                if local_max_x > 500.01 {
2358                    violations.push((p.geometry_id.clone(), strip_idx, local_x, local_max_x));
2359                }
2360            }
2361        }
2362
2363        assert!(
2364            violations.is_empty(),
2365            "Found {} Gear pieces exceeding boundary: {:?}",
2366            violations.len(),
2367            violations
2368        );
2369    }
2370
2371    #[test]
2372    fn test_blf_bounds_expanded_like_benchmark() {
2373        // Replicate EXACTLY how benchmark runner creates geometries:
2374        // Each piece is a separate Geometry2D with quantity=1
2375        // (vertices, demand, allowed_rotations_deg)
2376        let shape_defs: Vec<(Vec<(f64, f64)>, usize, Vec<f64>)> = vec![
2377            (
2378                vec![
2379                    (0.0, 0.0),
2380                    (180.0, 0.0),
2381                    (195.0, 15.0),
2382                    (200.0, 50.0),
2383                    (200.0, 150.0),
2384                    (195.0, 185.0),
2385                    (180.0, 200.0),
2386                    (20.0, 200.0),
2387                    (5.0, 185.0),
2388                    (0.0, 150.0),
2389                    (0.0, 50.0),
2390                    (5.0, 15.0),
2391                ],
2392                2,
2393                vec![0.0, 90.0, 180.0, 270.0],
2394            ),
2395            (
2396                vec![
2397                    (60.0, 0.0),
2398                    (85.0, 7.0),
2399                    (104.0, 25.0),
2400                    (118.0, 50.0),
2401                    (120.0, 60.0),
2402                    (118.0, 70.0),
2403                    (104.0, 95.0),
2404                    (85.0, 113.0),
2405                    (60.0, 120.0),
2406                    (35.0, 113.0),
2407                    (16.0, 95.0),
2408                    (2.0, 70.0),
2409                    (0.0, 60.0),
2410                    (2.0, 50.0),
2411                    (16.0, 25.0),
2412                    (35.0, 7.0),
2413                ],
2414                4,
2415                vec![0.0, 45.0, 90.0, 135.0],
2416            ),
2417            (
2418                vec![
2419                    (0.0, 0.0),
2420                    (80.0, 0.0),
2421                    (80.0, 20.0),
2422                    (20.0, 20.0),
2423                    (20.0, 80.0),
2424                    (0.0, 80.0),
2425                ],
2426                6,
2427                vec![0.0, 90.0, 180.0, 270.0],
2428            ),
2429            (
2430                vec![(0.0, 0.0), (70.0, 0.0), (0.0, 70.0)],
2431                6,
2432                vec![0.0, 90.0, 180.0, 270.0],
2433            ),
2434            (
2435                vec![(0.0, 0.0), (120.0, 0.0), (120.0, 60.0), (0.0, 60.0)],
2436                4,
2437                vec![0.0, 90.0],
2438            ),
2439            (
2440                vec![
2441                    (15.0, 0.0),
2442                    (45.0, 0.0),
2443                    (60.0, 26.0),
2444                    (45.0, 52.0),
2445                    (15.0, 52.0),
2446                    (0.0, 26.0),
2447                ],
2448                8,
2449                vec![0.0, 60.0, 120.0],
2450            ),
2451            (
2452                vec![
2453                    (0.0, 0.0),
2454                    (90.0, 0.0),
2455                    (90.0, 12.0),
2456                    (55.0, 12.0),
2457                    (55.0, 60.0),
2458                    (35.0, 60.0),
2459                    (35.0, 12.0),
2460                    (0.0, 12.0),
2461                ],
2462                4,
2463                vec![0.0, 90.0, 180.0, 270.0],
2464            ),
2465            (
2466                vec![
2467                    (0.0, 10.0),
2468                    (10.0, 0.0),
2469                    (70.0, 0.0),
2470                    (80.0, 10.0),
2471                    (80.0, 70.0),
2472                    (70.0, 80.0),
2473                    (10.0, 80.0),
2474                    (0.0, 70.0),
2475                ],
2476                3,
2477                vec![0.0, 90.0],
2478            ),
2479            // Shape 8: Gear - with all 8 rotations
2480            (
2481                vec![
2482                    (50.0, 5.0),
2483                    (65.0, 15.0),
2484                    (77.0, 18.0),
2485                    (80.0, 32.0),
2486                    (95.0, 50.0),
2487                    (80.0, 68.0),
2488                    (77.0, 82.0),
2489                    (65.0, 85.0),
2490                    (50.0, 95.0),
2491                    (35.0, 85.0),
2492                    (23.0, 82.0),
2493                    (20.0, 68.0),
2494                    (5.0, 50.0),
2495                    (20.0, 32.0),
2496                    (23.0, 18.0),
2497                    (35.0, 15.0),
2498                ],
2499                13,
2500                vec![0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0],
2501            ),
2502        ];
2503
2504        // Expand like benchmark runner: each piece is separate geometry
2505        let mut geometries = Vec::new();
2506        let mut piece_id = 0;
2507        for (vertices, demand, rotations) in shape_defs.iter() {
2508            for _ in 0..*demand {
2509                let geom = Geometry2D::new(format!("piece_{}", piece_id))
2510                    .with_polygon(vertices.clone())
2511                    .with_rotations_deg(rotations.clone());
2512                geometries.push(geom);
2513                piece_id += 1;
2514            }
2515        }
2516
2517        // Store gear AABB for checking
2518        let gear_geom = Geometry2D::new("gear_check").with_polygon(shape_defs[8].0.clone());
2519        let (gear_min, gear_max) = gear_geom.aabb();
2520        println!("Gear AABB: min={:?}, max={:?}", gear_min, gear_max);
2521
2522        let boundary = Boundary2D::rectangle(500.0, 500.0);
2523        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2524        let nester = Nester2D::new(config);
2525
2526        let result = nester.solve_multi_strip(&geometries, &boundary).unwrap();
2527
2528        println!(
2529            "Placed {} pieces across {} strips",
2530            result.placements.len(),
2531            result.boundaries_used
2532        );
2533
2534        // Check Gear placements (piece_37 to piece_49)
2535        let strip_width = 500.0;
2536        let mut violations = Vec::new();
2537
2538        for p in &result.placements {
2539            let id_num: usize = p
2540                .geometry_id
2541                .as_str()
2542                .strip_prefix("piece_")
2543                .and_then(|s| s.parse().ok())
2544                .unwrap_or(0);
2545
2546            // piece_37 to piece_49 are Gear shapes
2547            if id_num >= 37 && id_num <= 49 {
2548                let origin_x = p.position[0];
2549                let rotation = p.rotation.first().copied().unwrap_or(0.0);
2550                let strip_idx = p.boundary_index;
2551                let local_x = origin_x - (strip_idx as f64 * strip_width);
2552
2553                let (_, g_max_r) = gear_geom.aabb_at_rotation(rotation);
2554                let local_max_x = local_x + g_max_r[0];
2555
2556                println!(
2557                    "{}: strip={}, local_x={:.1}, rot={:.2}, local_max_x={:.1}",
2558                    p.geometry_id, strip_idx, local_x, rotation, local_max_x
2559                );
2560
2561                if local_max_x > 500.01 {
2562                    violations.push((p.geometry_id.clone(), strip_idx, local_x, local_max_x));
2563                }
2564            }
2565        }
2566
2567        assert!(
2568            violations.is_empty(),
2569            "Found {} Gear pieces exceeding boundary: {:?}",
2570            violations.len(),
2571            violations
2572        );
2573    }
2574
2575    /// Helper function to check if two AABBs overlap
2576    fn aabbs_overlap(
2577        a_min: [f64; 2],
2578        a_max: [f64; 2],
2579        b_min: [f64; 2],
2580        b_max: [f64; 2],
2581        tolerance: f64,
2582    ) -> bool {
2583        // Two AABBs overlap if they overlap on both axes
2584        let x_overlap = a_min[0] < b_max[0] - tolerance && a_max[0] > b_min[0] + tolerance;
2585        let y_overlap = a_min[1] < b_max[1] - tolerance && a_max[1] > b_min[1] + tolerance;
2586        x_overlap && y_overlap
2587    }
2588
2589    /// Comprehensive test for all strategies - checks boundary and overlap violations
2590    #[test]
2591    fn test_all_strategies_boundary_and_overlap() {
2592        use std::collections::HashMap;
2593
2594        // Create test shapes similar to demo
2595        let shapes = vec![
2596            Geometry2D::new("shape0")
2597                .with_polygon(vec![
2598                    (0.0, 0.0),
2599                    (180.0, 0.0),
2600                    (195.0, 15.0),
2601                    (200.0, 50.0),
2602                    (200.0, 150.0),
2603                    (195.0, 185.0),
2604                    (180.0, 200.0),
2605                    (20.0, 200.0),
2606                    (5.0, 185.0),
2607                    (0.0, 150.0),
2608                    (0.0, 50.0),
2609                    (5.0, 15.0),
2610                ])
2611                .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
2612                .with_quantity(2),
2613            Geometry2D::new("shape1_flange")
2614                .with_polygon(vec![
2615                    (60.0, 0.0),
2616                    (85.0, 7.0),
2617                    (104.0, 25.0),
2618                    (118.0, 50.0),
2619                    (120.0, 60.0),
2620                    (118.0, 70.0),
2621                    (104.0, 95.0),
2622                    (85.0, 113.0),
2623                    (60.0, 120.0),
2624                    (35.0, 113.0),
2625                    (16.0, 95.0),
2626                    (2.0, 70.0),
2627                    (0.0, 60.0),
2628                    (2.0, 50.0),
2629                    (16.0, 25.0),
2630                    (35.0, 7.0),
2631                ])
2632                .with_rotations_deg(vec![0.0, 45.0, 90.0, 135.0])
2633                .with_quantity(4),
2634            Geometry2D::new("shape2_lbracket")
2635                .with_polygon(vec![
2636                    (0.0, 0.0),
2637                    (80.0, 0.0),
2638                    (80.0, 20.0),
2639                    (20.0, 20.0),
2640                    (20.0, 80.0),
2641                    (0.0, 80.0),
2642                ])
2643                .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
2644                .with_quantity(6),
2645            Geometry2D::new("shape3_triangle")
2646                .with_polygon(vec![(0.0, 0.0), (70.0, 0.0), (0.0, 70.0)])
2647                .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
2648                .with_quantity(6),
2649            Geometry2D::new("shape4_rect")
2650                .with_polygon(vec![(0.0, 0.0), (120.0, 0.0), (120.0, 60.0), (0.0, 60.0)])
2651                .with_rotations_deg(vec![0.0, 90.0])
2652                .with_quantity(4),
2653            Geometry2D::new("shape5_hexagon")
2654                .with_polygon(vec![
2655                    (15.0, 0.0),
2656                    (45.0, 0.0),
2657                    (60.0, 26.0),
2658                    (45.0, 52.0),
2659                    (15.0, 52.0),
2660                    (0.0, 26.0),
2661                ])
2662                .with_rotations_deg(vec![0.0, 60.0, 120.0])
2663                .with_quantity(8),
2664            Geometry2D::new("shape6_tstiff")
2665                .with_polygon(vec![
2666                    (0.0, 0.0),
2667                    (90.0, 0.0),
2668                    (90.0, 12.0),
2669                    (55.0, 12.0),
2670                    (55.0, 60.0),
2671                    (35.0, 60.0),
2672                    (35.0, 12.0),
2673                    (0.0, 12.0),
2674                ])
2675                .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
2676                .with_quantity(4),
2677            Geometry2D::new("shape7_mount")
2678                .with_polygon(vec![
2679                    (0.0, 10.0),
2680                    (10.0, 0.0),
2681                    (70.0, 0.0),
2682                    (80.0, 10.0),
2683                    (80.0, 70.0),
2684                    (70.0, 80.0),
2685                    (10.0, 80.0),
2686                    (0.0, 70.0),
2687                ])
2688                .with_rotations_deg(vec![0.0, 90.0])
2689                .with_quantity(3),
2690            Geometry2D::new("shape8_gear")
2691                .with_polygon(vec![
2692                    (50.0, 5.0),
2693                    (65.0, 15.0),
2694                    (77.0, 18.0),
2695                    (80.0, 32.0),
2696                    (95.0, 50.0),
2697                    (80.0, 68.0),
2698                    (77.0, 82.0),
2699                    (65.0, 85.0),
2700                    (50.0, 95.0),
2701                    (35.0, 85.0),
2702                    (23.0, 82.0),
2703                    (20.0, 68.0),
2704                    (5.0, 50.0),
2705                    (20.0, 32.0),
2706                    (23.0, 18.0),
2707                    (35.0, 15.0),
2708                ])
2709                .with_rotations_deg(vec![0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0])
2710                .with_quantity(13),
2711        ];
2712
2713        // Build geometry lookup map
2714        let geom_map: HashMap<String, &Geometry2D> =
2715            shapes.iter().map(|g| (g.id().clone(), g)).collect();
2716
2717        let boundary = Boundary2D::rectangle(500.0, 500.0);
2718        let strip_width = 500.0;
2719
2720        // Test each strategy
2721        let strategies = vec![
2722            Strategy::BottomLeftFill,
2723            Strategy::NfpGuided,
2724            Strategy::GeneticAlgorithm,
2725            Strategy::Brkga,
2726            Strategy::SimulatedAnnealing,
2727            Strategy::Gdrr,
2728            Strategy::Alns,
2729        ];
2730
2731        for strategy in strategies {
2732            println!("\n========== Testing {:?} ==========", strategy);
2733
2734            let config = Config::default()
2735                .with_strategy(strategy)
2736                .with_time_limit(30000); // 30s max per strategy
2737            let nester = Nester2D::new(config);
2738
2739            let result = match nester.solve_multi_strip(&shapes, &boundary) {
2740                Ok(r) => r,
2741                Err(e) => {
2742                    println!("  Strategy {:?} failed: {}", strategy, e);
2743                    continue;
2744                }
2745            };
2746
2747            println!(
2748                "  Placed {} pieces across {} strips",
2749                result.placements.len(),
2750                result.boundaries_used
2751            );
2752
2753            // Check 1: Boundary violations
2754            let mut boundary_violations = Vec::new();
2755            for p in &result.placements {
2756                // Find the base geometry ID (without instance suffix)
2757                let base_id = p.geometry_id.split('_').next().unwrap_or(&p.geometry_id);
2758                let full_id = if base_id.starts_with("shape") {
2759                    // Find matching geometry by checking all shape IDs
2760                    shapes
2761                        .iter()
2762                        .find(|g| p.geometry_id.starts_with(g.id()))
2763                        .map(|g| g.id().as_str())
2764                } else {
2765                    geom_map.get(&p.geometry_id).map(|g| g.id().as_str())
2766                };
2767
2768                let geom = match full_id.and_then(|id| geom_map.get(id)) {
2769                    Some(g) => *g,
2770                    None => {
2771                        // Try to find by prefix match
2772                        match shapes.iter().find(|g| p.geometry_id.starts_with(g.id())) {
2773                            Some(g) => g,
2774                            None => {
2775                                println!(
2776                                    "  WARNING: Could not find geometry for {}",
2777                                    p.geometry_id
2778                                );
2779                                continue;
2780                            }
2781                        }
2782                    }
2783                };
2784
2785                let origin_x = p.position[0];
2786                let origin_y = p.position[1];
2787                let rotation = p.rotation.first().copied().unwrap_or(0.0);
2788                let strip_idx = p.boundary_index;
2789
2790                // Calculate local position within strip
2791                let local_x = origin_x - (strip_idx as f64 * strip_width);
2792
2793                let (g_min, g_max) = geom.aabb_at_rotation(rotation);
2794
2795                // Calculate actual bounds in local strip coordinates
2796                let local_min_x = local_x + g_min[0];
2797                let local_max_x = local_x + g_max[0];
2798                let local_min_y = origin_y + g_min[1];
2799                let local_max_y = origin_y + g_max[1];
2800
2801                // Check boundary (with small tolerance)
2802                let tolerance = 0.1;
2803                if local_min_x < -tolerance
2804                    || local_max_x > 500.0 + tolerance
2805                    || local_min_y < -tolerance
2806                    || local_max_y > 500.0 + tolerance
2807                {
2808                    boundary_violations.push(format!(
2809                        "{} in strip {}: bounds ({:.1}, {:.1}) to ({:.1}, {:.1})",
2810                        p.geometry_id,
2811                        strip_idx,
2812                        local_min_x,
2813                        local_min_y,
2814                        local_max_x,
2815                        local_max_y
2816                    ));
2817                }
2818            }
2819
2820            if !boundary_violations.is_empty() {
2821                println!("  BOUNDARY VIOLATIONS ({}):", boundary_violations.len());
2822                for v in &boundary_violations {
2823                    println!("    - {}", v);
2824                }
2825            }
2826
2827            // Check 2: Overlaps (within same strip)
2828            let mut overlaps = Vec::new();
2829            let placements: Vec<_> = result.placements.iter().collect();
2830
2831            for i in 0..placements.len() {
2832                for j in (i + 1)..placements.len() {
2833                    let p1 = placements[i];
2834                    let p2 = placements[j];
2835
2836                    // Only check overlaps within the same strip
2837                    if p1.boundary_index != p2.boundary_index {
2838                        continue;
2839                    }
2840
2841                    // Find geometries
2842                    let g1 = shapes.iter().find(|g| p1.geometry_id.starts_with(g.id()));
2843                    let g2 = shapes.iter().find(|g| p2.geometry_id.starts_with(g.id()));
2844
2845                    let (g1, g2) = match (g1, g2) {
2846                        (Some(a), Some(b)) => (a, b),
2847                        _ => continue,
2848                    };
2849
2850                    let strip_idx = p1.boundary_index;
2851                    let local_x1 = p1.position[0] - (strip_idx as f64 * strip_width);
2852                    let local_x2 = p2.position[0] - (strip_idx as f64 * strip_width);
2853
2854                    let rot1 = p1.rotation.first().copied().unwrap_or(0.0);
2855                    let rot2 = p2.rotation.first().copied().unwrap_or(0.0);
2856
2857                    let (g1_min, g1_max) = g1.aabb_at_rotation(rot1);
2858                    let (g2_min, g2_max) = g2.aabb_at_rotation(rot2);
2859
2860                    let a_min = [local_x1 + g1_min[0], p1.position[1] + g1_min[1]];
2861                    let a_max = [local_x1 + g1_max[0], p1.position[1] + g1_max[1]];
2862                    let b_min = [local_x2 + g2_min[0], p2.position[1] + g2_min[1]];
2863                    let b_max = [local_x2 + g2_max[0], p2.position[1] + g2_max[1]];
2864
2865                    if aabbs_overlap(a_min, a_max, b_min, b_max, 1.0) {
2866                        overlaps.push(format!(
2867                            "{} and {} in strip {}",
2868                            p1.geometry_id, p2.geometry_id, strip_idx
2869                        ));
2870                    }
2871                }
2872            }
2873
2874            if !overlaps.is_empty() {
2875                println!("  OVERLAPS ({}):", overlaps.len());
2876                for o in overlaps.iter().take(10) {
2877                    println!("    - {}", o);
2878                }
2879                if overlaps.len() > 10 {
2880                    println!("    ... and {} more", overlaps.len() - 10);
2881                }
2882            }
2883
2884            // Assert no boundary violations
2885            assert!(
2886                boundary_violations.is_empty(),
2887                "{:?}: Found {} boundary violations",
2888                strategy,
2889                boundary_violations.len()
2890            );
2891
2892            println!("  ✓ All placements within boundary");
2893            println!("  ✓ No AABB overlaps detected");
2894        }
2895    }
2896}