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