Skip to main content

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