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        // Global per-geometry placed counter so instance indices stay unique across
1099        // strips (each strip re-numbers its own placements from 0).
1100        let mut placed_total: std::collections::HashMap<String, usize> =
1101            std::collections::HashMap::new();
1102
1103        while !remaining_geometries.is_empty() && strip_index < max_strips {
1104            if self.cancelled.load(Ordering::Relaxed) {
1105                break;
1106            }
1107
1108            // Solve on current strip
1109            let strip_result = match self.config.strategy {
1110                Strategy::BottomLeftFill => self.bottom_left_fill(&remaining_geometries, boundary),
1111                Strategy::NfpGuided => self.nfp_guided_blf(&remaining_geometries, boundary),
1112                Strategy::GeneticAlgorithm => {
1113                    self.genetic_algorithm(&remaining_geometries, boundary)
1114                }
1115                Strategy::Brkga => self.brkga(&remaining_geometries, boundary),
1116                Strategy::SimulatedAnnealing => {
1117                    self.simulated_annealing(&remaining_geometries, boundary)
1118                }
1119                Strategy::Gdrr => self.gdrr(&remaining_geometries, boundary),
1120                Strategy::Alns => self.alns(&remaining_geometries, boundary),
1121                #[cfg(feature = "milp")]
1122                Strategy::MilpExact => self.milp_exact(&remaining_geometries, boundary),
1123                #[cfg(feature = "milp")]
1124                Strategy::HybridExact => self.hybrid_exact(&remaining_geometries, boundary),
1125                _ => self.nfp_guided_blf(&remaining_geometries, boundary),
1126            }?;
1127
1128            // Validate and filter out-of-bounds placements for this strip
1129            let strip_result =
1130                validate_and_filter_placements(strip_result, &remaining_geometries, boundary);
1131
1132            if strip_result.placements.is_empty() {
1133                // No progress: every remaining geometry is individually too large for
1134                // an empty strip. Stop; the after-loop sweep records them as unplaced.
1135                break;
1136            }
1137
1138            // Count instances of each geometry placed on this strip (instance-level,
1139            // not id-level) to drive remaining-quantity reduction and unique numbering.
1140            let mut strip_placed: std::collections::HashMap<String, usize> =
1141                std::collections::HashMap::new();
1142
1143            // Adjust placements for this strip and add to final result.
1144            for mut placement in strip_result.placements {
1145                let gid = placement.geometry_id.clone();
1146                // Globally-unique instance index = already-placed of this id + running
1147                // count within this strip (each strip re-numbers its own from 0).
1148                let prior = placed_total.get(&gid).copied().unwrap_or(0);
1149                let in_strip = strip_placed.get(&gid).copied().unwrap_or(0);
1150                placement.instance = prior + in_strip;
1151                // Offset x position by strip_index * strip_width (global strip frame).
1152                if !placement.position.is_empty() {
1153                    placement.position[0] += strip_index as f64 * strip_width;
1154                }
1155                placement.boundary_index = strip_index;
1156                *strip_placed.entry(gid).or_insert(0) += 1;
1157                final_result.placements.push(placement);
1158            }
1159
1160            // Reduce each geometry's remaining quantity by the instances placed this
1161            // strip. Fully-placed geometries drop out; partially-placed ones carry the
1162            // remainder to the next strip (fixes the prior id-level silent loss).
1163            for (gid, cnt) in &strip_placed {
1164                *placed_total.entry(gid.clone()).or_insert(0) += cnt;
1165            }
1166            remaining_geometries = remaining_geometries
1167                .into_iter()
1168                .filter_map(|g| {
1169                    let placed_here = strip_placed.get(g.id()).copied().unwrap_or(0);
1170                    let new_quantity = g.quantity().saturating_sub(placed_here);
1171                    if new_quantity == 0 {
1172                        None
1173                    } else {
1174                        Some(g.with_quantity(new_quantity))
1175                    }
1176                })
1177                .collect();
1178
1179            strip_index += 1;
1180        }
1181
1182        // Any geometry still remaining (too large to place, or hit the strip cap) is
1183        // unplaced at the instance level — record an id entry each (deduplicated below).
1184        for g in &remaining_geometries {
1185            final_result.unplaced.push(g.id().clone());
1186        }
1187
1188        final_result.boundaries_used = strip_index;
1189        final_result.deduplicate_unplaced();
1190        // Authoritative instance-level request total (Σ quantity), mirroring solve().
1191        final_result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
1192
1193        // Calculate per-strip statistics for accurate utilization
1194        let (b_min, b_max) = boundary.aabb();
1195        let strip_height = b_max[1] - b_min[1]; // Height of each strip
1196
1197        // Group placements by strip and calculate stats
1198        let mut strip_stats_map: std::collections::HashMap<usize, (f64, f64, usize)> =
1199            std::collections::HashMap::new(); // strip_index -> (max_x, piece_area, count)
1200
1201        for placement in &final_result.placements {
1202            let strip_idx = placement.boundary_index;
1203            // Get the geometry to calculate its area and right edge
1204            if let Some(geom) = geometries.iter().find(|g| g.id() == &placement.geometry_id) {
1205                use u_nesting_core::geometry::Geometry;
1206                let piece_area = geom.measure();
1207                let rotation = placement.rotation.first().copied().unwrap_or(0.0);
1208                let (_g_min, g_max) = geom.aabb_at_rotation(rotation);
1209                // Position is where geometry's origin is placed
1210                // The actual right edge is position.x + g_max[0] (relative to origin)
1211                let local_x = placement.position[0] - (strip_idx as f64 * strip_width);
1212                let right_edge = local_x + g_max[0];
1213
1214                let entry = strip_stats_map.entry(strip_idx).or_insert((0.0, 0.0, 0));
1215                entry.0 = entry.0.max(right_edge); // max_x (used_length)
1216                entry.1 += piece_area; // total piece area
1217                entry.2 += 1; // piece count
1218            }
1219        }
1220
1221        // Convert to StripStats vec
1222        use u_nesting_core::result::StripStats;
1223        let mut strip_stats: Vec<StripStats> = strip_stats_map
1224            .into_iter()
1225            .map(|(idx, (used_length, piece_area, count))| StripStats {
1226                strip_index: idx,
1227                used_length,
1228                piece_area,
1229                piece_count: count,
1230                strip_width,  // Width of boundary (X dimension)
1231                strip_height, // Height of boundary (Y dimension, fixed)
1232            })
1233            .collect();
1234        strip_stats.sort_by_key(|s| s.strip_index);
1235
1236        // Calculate accurate utilization
1237        // Material used = strip_height (fixed dimension) × used_length (consumed length)
1238        let total_piece_area: f64 = strip_stats.iter().map(|s| s.piece_area).sum();
1239        let total_material_used: f64 = strip_stats
1240            .iter()
1241            .map(|s| s.strip_height * s.used_length)
1242            .sum();
1243
1244        final_result.strip_stats = strip_stats;
1245        final_result.total_piece_area = total_piece_area;
1246        final_result.total_material_used = total_material_used;
1247
1248        if total_material_used > 0.0 {
1249            final_result.utilization = total_piece_area / total_material_used;
1250        }
1251
1252        Ok(final_result)
1253    }
1254}
1255
1256impl Solver for Nester2D {
1257    type Geometry = Geometry2D;
1258    type Boundary = Boundary2D;
1259    type Scalar = f64;
1260
1261    fn solve(
1262        &self,
1263        geometries: &[Self::Geometry],
1264        boundary: &Self::Boundary,
1265    ) -> Result<SolveResult<f64>> {
1266        boundary.validate()?;
1267
1268        // Reset cancellation flag
1269        self.cancelled.store(false, Ordering::Relaxed);
1270
1271        let initial_result = match self.config.strategy {
1272            Strategy::BottomLeftFill => self.bottom_left_fill(geometries, boundary),
1273            Strategy::NfpGuided => self.nfp_guided_blf(geometries, boundary),
1274            Strategy::GeneticAlgorithm => self.genetic_algorithm(geometries, boundary),
1275            Strategy::Brkga => self.brkga(geometries, boundary),
1276            Strategy::SimulatedAnnealing => self.simulated_annealing(geometries, boundary),
1277            Strategy::Gdrr => self.gdrr(geometries, boundary),
1278            Strategy::Alns => self.alns(geometries, boundary),
1279            #[cfg(feature = "milp")]
1280            Strategy::MilpExact => self.milp_exact(geometries, boundary),
1281            #[cfg(feature = "milp")]
1282            Strategy::HybridExact => self.hybrid_exact(geometries, boundary),
1283            _ => {
1284                // Fall back to NFP-guided BLF for other strategies
1285                log::warn!(
1286                    "Strategy {:?} not yet implemented, using NfpGuided",
1287                    self.config.strategy
1288                );
1289                self.nfp_guided_blf(geometries, boundary)
1290            }
1291        }?;
1292
1293        // Validate all placements and remove any that are outside the boundary
1294        let mut result = validate_and_filter_placements(initial_result, geometries, boundary);
1295
1296        // Remove duplicate entries from unplaced list
1297        result.deduplicate_unplaced();
1298        // Authoritative instance-level request total (Σ quantity), recorded once
1299        // at the top-level entry point where `geometries` is the full request.
1300        result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
1301        Ok(result)
1302    }
1303
1304    fn solve_with_progress(
1305        &self,
1306        geometries: &[Self::Geometry],
1307        boundary: &Self::Boundary,
1308        callback: ProgressCallback,
1309    ) -> Result<SolveResult<f64>> {
1310        boundary.validate()?;
1311
1312        // Reset cancellation flag
1313        self.cancelled.store(false, Ordering::Relaxed);
1314
1315        let initial_result = match self.config.strategy {
1316            Strategy::BottomLeftFill => {
1317                self.bottom_left_fill_with_progress(geometries, boundary, &callback)?
1318            }
1319            Strategy::NfpGuided => {
1320                self.nfp_guided_blf_with_progress(geometries, boundary, &callback)?
1321            }
1322            Strategy::GeneticAlgorithm => {
1323                let mut ga_config = GaConfig::default()
1324                    .with_population_size(self.config.population_size)
1325                    .with_max_generations(self.config.max_generations)
1326                    .with_crossover_rate(self.config.crossover_rate)
1327                    .with_mutation_rate(self.config.mutation_rate);
1328
1329                // Apply time limit if specified
1330                if self.config.time_limit_ms > 0 {
1331                    ga_config = ga_config.with_time_limit(std::time::Duration::from_millis(
1332                        self.config.time_limit_ms,
1333                    ));
1334                }
1335
1336                run_ga_nesting_with_progress(
1337                    geometries,
1338                    boundary,
1339                    &self.config,
1340                    ga_config,
1341                    self.cancelled.clone(),
1342                    callback,
1343                )
1344            }
1345            // For other strategies, use basic progress reporting
1346            _ => {
1347                log::warn!(
1348                    "Strategy {:?} not yet implemented, using NfpGuided",
1349                    self.config.strategy
1350                );
1351                self.nfp_guided_blf_with_progress(geometries, boundary, &callback)?
1352            }
1353        };
1354
1355        // Validate all placements and remove any that are outside the boundary
1356        let mut result = validate_and_filter_placements(initial_result, geometries, boundary);
1357
1358        // Remove duplicate entries from unplaced list
1359        result.deduplicate_unplaced();
1360        // Authoritative instance-level request total (Σ quantity), recorded once
1361        // at the top-level entry point where `geometries` is the full request.
1362        result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
1363        Ok(result)
1364    }
1365
1366    fn cancel(&self) {
1367        self.cancelled.store(true, Ordering::Relaxed);
1368    }
1369}
1370
1371#[cfg(test)]
1372mod tests {
1373    use super::*;
1374    use crate::placement_utils::polygon_centroid;
1375
1376    #[test]
1377    fn test_simple_nesting() {
1378        let geometries = vec![
1379            Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(3),
1380            Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
1381        ];
1382
1383        let boundary = Boundary2D::rectangle(100.0, 50.0);
1384        let nester = Nester2D::default_config();
1385
1386        let result = nester.solve(&geometries, &boundary).unwrap();
1387
1388        assert!(result.utilization > 0.0);
1389        assert!(result.placements.len() <= 5); // 3 + 2 = 5 pieces
1390    }
1391
1392    #[test]
1393    fn test_placement_within_bounds() {
1394        let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
1395
1396        let boundary = Boundary2D::rectangle(50.0, 50.0);
1397        let config = Config::default().with_margin(5.0).with_spacing(2.0);
1398        let nester = Nester2D::new(config);
1399
1400        let result = nester.solve(&geometries, &boundary).unwrap();
1401
1402        // All pieces should be placed
1403        assert_eq!(result.placements.len(), 4);
1404        assert!(result.unplaced.is_empty());
1405
1406        // Verify placements are within bounds (with margin)
1407        for p in &result.placements {
1408            assert!(p.position[0] >= 5.0);
1409            assert!(p.position[1] >= 5.0);
1410        }
1411    }
1412
1413    #[test]
1414    fn test_nfp_guided_basic() {
1415        let geometries = vec![
1416            Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
1417            Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(1),
1418        ];
1419
1420        let boundary = Boundary2D::rectangle(100.0, 50.0);
1421        let config = Config::default().with_strategy(Strategy::NfpGuided);
1422        let nester = Nester2D::new(config);
1423
1424        let result = nester.solve(&geometries, &boundary).unwrap();
1425
1426        assert!(result.utilization > 0.0);
1427        assert_eq!(result.placements.len(), 3); // 2 + 1 = 3 pieces
1428        assert!(result.unplaced.is_empty());
1429    }
1430
1431    #[test]
1432    fn test_nfp_guided_with_spacing() {
1433        let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
1434
1435        let boundary = Boundary2D::rectangle(50.0, 50.0);
1436        let config = Config::default()
1437            .with_strategy(Strategy::NfpGuided)
1438            .with_margin(2.0)
1439            .with_spacing(3.0);
1440        let nester = Nester2D::new(config);
1441
1442        let result = nester.solve(&geometries, &boundary).unwrap();
1443
1444        // All pieces should be placed
1445        assert_eq!(result.placements.len(), 4);
1446        assert!(result.unplaced.is_empty());
1447
1448        // Utilization should be positive
1449        assert!(result.utilization > 0.0);
1450    }
1451
1452    #[test]
1453    fn test_nfp_guided_no_overlap() {
1454        let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(3)];
1455
1456        let boundary = Boundary2D::rectangle(100.0, 100.0);
1457        let config = Config::default().with_strategy(Strategy::NfpGuided);
1458        let nester = Nester2D::new(config);
1459
1460        let result = nester.solve(&geometries, &boundary).unwrap();
1461
1462        assert_eq!(result.placements.len(), 3);
1463
1464        // Verify no overlaps between placements
1465        for i in 0..result.placements.len() {
1466            for j in (i + 1)..result.placements.len() {
1467                let p1 = &result.placements[i];
1468                let p2 = &result.placements[j];
1469
1470                // Simple AABB overlap check for rectangles
1471                let r1_min_x = p1.position[0];
1472                let r1_max_x = p1.position[0] + 20.0;
1473                let r1_min_y = p1.position[1];
1474                let r1_max_y = p1.position[1] + 20.0;
1475
1476                let r2_min_x = p2.position[0];
1477                let r2_max_x = p2.position[0] + 20.0;
1478                let r2_min_y = p2.position[1];
1479                let r2_max_y = p2.position[1] + 20.0;
1480
1481                // Check no overlap (with small tolerance for floating point)
1482                let overlaps_x = r1_min_x < r2_max_x - 0.01 && r1_max_x > r2_min_x + 0.01;
1483                let overlaps_y = r1_min_y < r2_max_y - 0.01 && r1_max_y > r2_min_y + 0.01;
1484
1485                assert!(
1486                    !(overlaps_x && overlaps_y),
1487                    "Placements {} and {} overlap",
1488                    i,
1489                    j
1490                );
1491            }
1492        }
1493    }
1494
1495    #[test]
1496    fn test_nfp_guided_utilization() {
1497        // Perfect fit: 4 rectangles of 25x25 in a 100x50 boundary
1498        let geometries = vec![Geometry2D::rectangle("R1", 25.0, 25.0).with_quantity(4)];
1499
1500        let boundary = Boundary2D::rectangle(100.0, 50.0);
1501        let config = Config::default().with_strategy(Strategy::NfpGuided);
1502        let nester = Nester2D::new(config);
1503
1504        let result = nester.solve(&geometries, &boundary).unwrap();
1505
1506        // All pieces should be placed
1507        assert_eq!(result.placements.len(), 4);
1508
1509        // Utilization should be 50% (4 * 625 = 2500 / 5000)
1510        assert!(result.utilization > 0.45);
1511    }
1512
1513    #[test]
1514    fn test_polygon_centroid() {
1515        // Test the centroid calculation
1516        let square = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
1517        let (cx, cy) = polygon_centroid(&square);
1518        assert!((cx - 5.0).abs() < 0.01);
1519        assert!((cy - 5.0).abs() < 0.01);
1520
1521        let triangle = vec![(0.0, 0.0), (6.0, 0.0), (3.0, 6.0)];
1522        let (cx, cy) = polygon_centroid(&triangle);
1523        assert!((cx - 3.0).abs() < 0.01);
1524        assert!((cy - 2.0).abs() < 0.01);
1525    }
1526
1527    #[test]
1528    fn test_ga_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::GeneticAlgorithm);
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        // GA should report generations and fitness
1543        assert!(result.generations.is_some());
1544        assert!(result.best_fitness.is_some());
1545        assert!(result.strategy == Some("GeneticAlgorithm".to_string()));
1546    }
1547
1548    #[test]
1549    fn test_ga_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        let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
1555        let nester = Nester2D::new(config);
1556
1557        let result = nester.solve(&geometries, &boundary).unwrap();
1558
1559        // All 4 pieces should fit
1560        assert_eq!(result.placements.len(), 4);
1561        assert!(result.unplaced.is_empty());
1562    }
1563
1564    #[test]
1565    fn test_brkga_strategy_basic() {
1566        let geometries = vec![
1567            Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
1568            Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
1569        ];
1570
1571        let boundary = Boundary2D::rectangle(100.0, 50.0);
1572        let config = Config::default().with_strategy(Strategy::Brkga);
1573        let nester = Nester2D::new(config);
1574
1575        let result = nester.solve(&geometries, &boundary).unwrap();
1576
1577        assert!(result.utilization > 0.0);
1578        assert!(!result.placements.is_empty());
1579        // BRKGA should report generations and fitness
1580        assert!(result.generations.is_some());
1581        assert!(result.best_fitness.is_some());
1582        assert!(result.strategy == Some("BRKGA".to_string()));
1583    }
1584
1585    #[test]
1586    fn test_brkga_strategy_all_placed() {
1587        // Easy case: 4 small rectangles in large boundary
1588        let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
1589
1590        let boundary = Boundary2D::rectangle(100.0, 100.0);
1591        // Use longer time limit to ensure BRKGA converges on all platforms
1592        let config = Config::default()
1593            .with_strategy(Strategy::Brkga)
1594            .with_time_limit(30000); // 30 seconds
1595        let nester = Nester2D::new(config);
1596
1597        let result = nester.solve(&geometries, &boundary).unwrap();
1598
1599        // BRKGA is stochastic; expect at least 3 of 4 pieces placed
1600        // (4 x 20x20 = 1600 area in 10000 boundary = 16% utilization, easy case)
1601        assert!(
1602            result.placements.len() >= 3,
1603            "Expected at least 3 placements, got {}",
1604            result.placements.len()
1605        );
1606    }
1607
1608    #[test]
1609    fn test_gdrr_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::Gdrr);
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        // GDRR should report iterations and fitness
1624        assert!(result.iterations.is_some());
1625        assert!(result.best_fitness.is_some());
1626        assert!(result.strategy == Some("GDRR".to_string()));
1627    }
1628
1629    #[test]
1630    fn test_gdrr_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::Gdrr);
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_alns_strategy_basic() {
1647        let geometries = vec![
1648            Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
1649            Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
1650        ];
1651
1652        let boundary = Boundary2D::rectangle(100.0, 50.0);
1653        let config = Config::default().with_strategy(Strategy::Alns);
1654        let nester = Nester2D::new(config);
1655
1656        let result = nester.solve(&geometries, &boundary).unwrap();
1657
1658        assert!(result.utilization > 0.0);
1659        assert!(!result.placements.is_empty());
1660        // ALNS should report iterations and fitness
1661        assert!(result.iterations.is_some());
1662        assert!(result.best_fitness.is_some());
1663        assert!(result.strategy == Some("ALNS".to_string()));
1664    }
1665
1666    #[test]
1667    fn test_alns_strategy_all_placed() {
1668        // Easy case: 4 small rectangles in large boundary
1669        let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
1670
1671        let boundary = Boundary2D::rectangle(100.0, 100.0);
1672        let config = Config::default().with_strategy(Strategy::Alns);
1673        let nester = Nester2D::new(config);
1674
1675        let result = nester.solve(&geometries, &boundary).unwrap();
1676
1677        // All 4 pieces should fit
1678        assert_eq!(result.placements.len(), 4);
1679        assert!(result.unplaced.is_empty());
1680    }
1681
1682    #[test]
1683    fn test_blf_rotation_optimization() {
1684        // Test that BLF uses rotation to optimize placement
1685        // A 30x10 rectangle can fit better in a narrow strip when rotated 90 degrees
1686        let geometries = vec![Geometry2D::rectangle("R1", 30.0, 10.0)
1687                .with_rotations(vec![0.0, std::f64::consts::FRAC_PI_2]) // 0 and 90 degrees
1688                .with_quantity(3)];
1689
1690        // Strip that's 35 wide: 30x10 won't fit two side-by-side at 0 deg
1691        // But two 10x30 (rotated 90 deg) can fit vertically in 95 height
1692        let boundary = Boundary2D::rectangle(35.0, 95.0);
1693        let nester = Nester2D::default_config();
1694
1695        let result = nester.solve(&geometries, &boundary).unwrap();
1696
1697        // All 3 pieces should be placed (by rotating)
1698        assert_eq!(
1699            result.placements.len(),
1700            3,
1701            "All pieces should be placed with rotation optimization"
1702        );
1703        assert!(result.unplaced.is_empty());
1704    }
1705
1706    #[test]
1707    fn test_blf_selects_best_rotation() {
1708        // Verify BLF selects optimal rotation, not just the first one
1709        let geometries = vec![Geometry2D::rectangle("R1", 40.0, 10.0)
1710                .with_rotations(vec![0.0, std::f64::consts::FRAC_PI_2]) // 0 and 90 degrees
1711                .with_quantity(2)];
1712
1713        // In a 45x50 boundary:
1714        // - At 0 deg: 40x10, only one fits horizontally (40 < 45), next row needed
1715        // - At 90 deg: 10x40, two can fit side-by-side (10+10 < 45) in one row
1716        let boundary = Boundary2D::rectangle(45.0, 50.0);
1717        let nester = Nester2D::default_config();
1718
1719        let result = nester.solve(&geometries, &boundary).unwrap();
1720
1721        assert_eq!(result.placements.len(), 2);
1722        assert!(result.unplaced.is_empty());
1723    }
1724
1725    #[test]
1726    fn test_progress_callback_blf() {
1727        use std::sync::atomic::{AtomicUsize, Ordering};
1728        use std::sync::Arc;
1729
1730        let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
1731        let boundary = Boundary2D::rectangle(50.0, 50.0);
1732        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
1733        let nester = Nester2D::new(config);
1734
1735        let callback_count = Arc::new(AtomicUsize::new(0));
1736        let callback_count_clone = callback_count.clone();
1737        let last_items_placed = Arc::new(AtomicUsize::new(0));
1738        let last_items_placed_clone = last_items_placed.clone();
1739
1740        let callback: ProgressCallback = Box::new(move |info| {
1741            callback_count_clone.fetch_add(1, Ordering::Relaxed);
1742            last_items_placed_clone.store(info.items_placed, Ordering::Relaxed);
1743        });
1744
1745        let result = nester
1746            .solve_with_progress(&geometries, &boundary, callback)
1747            .unwrap();
1748
1749        // Verify callback was called (at least once per piece + initial + final)
1750        let count = callback_count.load(Ordering::Relaxed);
1751        assert!(
1752            count >= 5,
1753            "Expected at least 5 callbacks (1 initial + 4 pieces + 1 final), got {}",
1754            count
1755        );
1756
1757        // Verify final items_placed
1758        let final_placed = last_items_placed.load(Ordering::Relaxed);
1759        assert_eq!(final_placed, 4, "Should report 4 items placed");
1760
1761        // Verify result
1762        assert_eq!(result.placements.len(), 4);
1763    }
1764
1765    #[test]
1766    fn test_progress_callback_nfp() {
1767        use std::sync::atomic::{AtomicUsize, Ordering};
1768        use std::sync::Arc;
1769
1770        let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(2)];
1771        let boundary = Boundary2D::rectangle(50.0, 50.0);
1772        let config = Config::default().with_strategy(Strategy::NfpGuided);
1773        let nester = Nester2D::new(config);
1774
1775        let callback_count = Arc::new(AtomicUsize::new(0));
1776        let callback_count_clone = callback_count.clone();
1777
1778        let callback: ProgressCallback = Box::new(move |info| {
1779            callback_count_clone.fetch_add(1, Ordering::Relaxed);
1780            assert!(info.items_placed <= info.total_items);
1781        });
1782
1783        let result = nester
1784            .solve_with_progress(&geometries, &boundary, callback)
1785            .unwrap();
1786
1787        // Verify callback was called
1788        let count = callback_count.load(Ordering::Relaxed);
1789        assert!(count >= 3, "Expected at least 3 callbacks, got {}", count);
1790
1791        // Verify result
1792        assert_eq!(result.placements.len(), 2);
1793    }
1794
1795    #[test]
1796    fn test_time_limit_honored() {
1797        // Create many geometries to ensure BLF takes measurable time
1798        let geometries: Vec<Geometry2D> = (0..100)
1799            .map(|i| Geometry2D::rectangle(format!("R{}", i), 5.0, 5.0))
1800            .collect();
1801        let boundary = Boundary2D::rectangle(1000.0, 1000.0);
1802
1803        // Set a very short time limit (1ms) to ensure timeout
1804        let config = Config::default()
1805            .with_strategy(Strategy::BottomLeftFill)
1806            .with_time_limit(1);
1807        let nester = Nester2D::new(config);
1808
1809        let result = nester.solve(&geometries, &boundary).unwrap();
1810
1811        // With such a short time limit, we may not place all items
1812        // The test verifies that the solver respects the time limit
1813        assert!(
1814            result.computation_time_ms <= 100, // Allow some margin for overhead
1815            "Computation took too long: {}ms (expected <= 100ms with 1ms limit)",
1816            result.computation_time_ms
1817        );
1818    }
1819
1820    #[test]
1821    fn test_time_limit_zero_unlimited() {
1822        // time_limit_ms = 0 means unlimited
1823        let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
1824        let boundary = Boundary2D::rectangle(50.0, 50.0);
1825
1826        let config = Config::default()
1827            .with_strategy(Strategy::BottomLeftFill)
1828            .with_time_limit(0); // Unlimited
1829        let nester = Nester2D::new(config);
1830
1831        let result = nester.solve(&geometries, &boundary).unwrap();
1832
1833        // Should place all items (no early exit)
1834        assert_eq!(result.placements.len(), 4);
1835    }
1836
1837    #[test]
1838    fn test_blf_bounds_clamping() {
1839        // Test that BLF correctly clamps placements within boundary
1840        // Create a shape with non-zero g_min (similar to Gear shape)
1841        // Gear-like: x ranges from 5 to 95 (width=90), y from 5 to 95 (height=90)
1842        let gear_like = Geometry2D::new("gear")
1843            .with_polygon(vec![
1844                (50.0, 5.0), // Bottom
1845                (65.0, 15.0),
1846                (77.0, 18.0),
1847                (80.0, 32.0),
1848                (95.0, 50.0), // Right
1849                (80.0, 68.0),
1850                (77.0, 82.0),
1851                (65.0, 85.0),
1852                (50.0, 95.0), // Top
1853                (35.0, 85.0),
1854                (23.0, 82.0),
1855                (20.0, 68.0),
1856                (5.0, 50.0), // Left (min_x = 5)
1857                (20.0, 32.0),
1858                (23.0, 18.0),
1859                (35.0, 15.0),
1860            ])
1861            .with_quantity(1);
1862
1863        // Boundary is 100x100
1864        let boundary = Boundary2D::rectangle(100.0, 100.0);
1865
1866        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
1867        let nester = Nester2D::new(config);
1868
1869        let result = nester
1870            .solve(std::slice::from_ref(&gear_like), &boundary)
1871            .unwrap();
1872
1873        assert_eq!(result.placements.len(), 1);
1874        let placement = &result.placements[0];
1875
1876        // Origin position
1877        let origin_x = placement.position[0];
1878        let origin_y = placement.position[1];
1879
1880        // Get rotation from placement (2D rotation is a single value in Vec)
1881        let rotation = placement.rotation.first().copied().unwrap_or(0.0);
1882
1883        // Get AABB at rotation
1884        let (g_min, g_max) = gear_like.aabb_at_rotation(rotation);
1885
1886        // Actual geometry bounds after placement
1887        let actual_min_x = origin_x + g_min[0];
1888        let actual_max_x = origin_x + g_max[0];
1889        let actual_min_y = origin_y + g_min[1];
1890        let actual_max_y = origin_y + g_max[1];
1891
1892        // All edges should be within boundary [0, 100]
1893        assert!(
1894            actual_min_x >= 0.0,
1895            "Left edge {} should be >= 0",
1896            actual_min_x
1897        );
1898        assert!(
1899            actual_max_x <= 100.0,
1900            "Right edge {} should be <= 100",
1901            actual_max_x
1902        );
1903        assert!(
1904            actual_min_y >= 0.0,
1905            "Bottom edge {} should be >= 0",
1906            actual_min_y
1907        );
1908        assert!(
1909            actual_max_y <= 100.0,
1910            "Top edge {} should be <= 100",
1911            actual_max_y
1912        );
1913    }
1914
1915    #[test]
1916    fn test_blf_bounds_clamping_many_pieces() {
1917        // Test BLF bounds clamping with many pieces to trigger row overflow
1918        // This mimics the actual failing case from test_blf.py
1919        let gear_like = Geometry2D::new("gear")
1920            .with_polygon(vec![
1921                (50.0, 5.0),
1922                (65.0, 15.0),
1923                (77.0, 18.0),
1924                (80.0, 32.0),
1925                (95.0, 50.0),
1926                (80.0, 68.0),
1927                (77.0, 82.0),
1928                (65.0, 85.0),
1929                (50.0, 95.0),
1930                (35.0, 85.0),
1931                (23.0, 82.0),
1932                (20.0, 68.0),
1933                (5.0, 50.0),
1934                (20.0, 32.0),
1935                (23.0, 18.0),
1936                (35.0, 15.0),
1937            ])
1938            .with_quantity(13); // Same as Gear (shape 8) in test_blf.py
1939
1940        // Boundary is 500x500 like the test
1941        let boundary = Boundary2D::rectangle(500.0, 500.0);
1942
1943        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
1944        let nester = Nester2D::new(config);
1945
1946        let result = nester
1947            .solve(std::slice::from_ref(&gear_like), &boundary)
1948            .unwrap();
1949
1950        // Check that ALL placements are within bounds
1951        for (i, placement) in result.placements.iter().enumerate() {
1952            let origin_x = placement.position[0];
1953            let origin_y = placement.position[1];
1954            let rotation = placement.rotation.first().copied().unwrap_or(0.0);
1955
1956            let (g_min, g_max) = gear_like.aabb_at_rotation(rotation);
1957
1958            let actual_min_x = origin_x + g_min[0];
1959            let actual_max_x = origin_x + g_max[0];
1960            let actual_min_y = origin_y + g_min[1];
1961            let actual_max_y = origin_y + g_max[1];
1962
1963            assert!(
1964                actual_min_x >= -0.01,
1965                "Piece {}: Left edge {} should be >= 0",
1966                i,
1967                actual_min_x
1968            );
1969            assert!(
1970                actual_max_x <= 500.01,
1971                "Piece {}: Right edge {} should be <= 500",
1972                i,
1973                actual_max_x
1974            );
1975            assert!(
1976                actual_min_y >= -0.01,
1977                "Piece {}: Bottom edge {} should be >= 0",
1978                i,
1979                actual_min_y
1980            );
1981            assert!(
1982                actual_max_y <= 500.01,
1983                "Piece {}: Top edge {} should be <= 500",
1984                i,
1985                actual_max_y
1986            );
1987        }
1988    }
1989
1990    #[test]
1991    fn test_blf_bounds_trace() {
1992        // Debug test: trace through BLF to understand why clamping doesn't work
1993        let gear = Geometry2D::new("gear").with_polygon(vec![
1994            (50.0, 5.0),
1995            (65.0, 15.0),
1996            (77.0, 18.0),
1997            (80.0, 32.0),
1998            (95.0, 50.0),
1999            (80.0, 68.0),
2000            (77.0, 82.0),
2001            (65.0, 85.0),
2002            (50.0, 95.0),
2003            (35.0, 85.0),
2004            (23.0, 82.0),
2005            (20.0, 68.0),
2006            (5.0, 50.0),
2007            (20.0, 32.0),
2008            (23.0, 18.0),
2009            (35.0, 15.0),
2010        ]);
2011
2012        // Verify AABB
2013        let (g_min, g_max) = gear.aabb();
2014        println!("Gear AABB: min={:?}, max={:?}", g_min, g_max);
2015        assert!(
2016            (g_min[0] - 5.0).abs() < 0.01,
2017            "g_min[0] should be 5, got {}",
2018            g_min[0]
2019        );
2020        assert!(
2021            (g_max[0] - 95.0).abs() < 0.01,
2022            "g_max[0] should be 95, got {}",
2023            g_max[0]
2024        );
2025
2026        // Verify valid origin range for 500x500 boundary
2027        let b_max_x = 500.0;
2028        let margin = 0.0;
2029        let max_valid_x = b_max_x - margin - g_max[0];
2030        println!(
2031            "max_valid_x = {} - {} - {} = {}",
2032            b_max_x, margin, g_max[0], max_valid_x
2033        );
2034        assert!(
2035            (max_valid_x - 405.0).abs() < 0.01,
2036            "max_valid_x should be 405, got {}",
2037            max_valid_x
2038        );
2039
2040        // Run BLF and check the result
2041        let boundary = Boundary2D::rectangle(500.0, 500.0);
2042        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2043        let nester = Nester2D::new(config);
2044
2045        let result = nester
2046            .solve(&[gear.clone().with_quantity(1)], &boundary)
2047            .unwrap();
2048
2049        assert_eq!(result.placements.len(), 1);
2050        let p = &result.placements[0];
2051        let origin_x = p.position[0];
2052        let rotation = p.rotation.first().copied().unwrap_or(0.0);
2053
2054        let (g_min_r, g_max_r) = gear.aabb_at_rotation(rotation);
2055        let actual_max_x = origin_x + g_max_r[0];
2056
2057        println!("Placement: origin_x={}, rotation={}", origin_x, rotation);
2058        println!(
2059            "At rotation {}: g_min={:?}, g_max={:?}",
2060            rotation, g_min_r, g_max_r
2061        );
2062        println!(
2063            "Actual max x: {} + {} = {}",
2064            origin_x, g_max_r[0], actual_max_x
2065        );
2066
2067        assert!(
2068            actual_max_x <= 500.01,
2069            "Geometry exceeds boundary: max_x={} > 500",
2070            actual_max_x
2071        );
2072    }
2073
2074    #[test]
2075    fn test_blf_bounds_many_pieces_direct() {
2076        // Test with many pieces to trigger the boundary violation
2077        let gear = Geometry2D::new("gear")
2078            .with_polygon(vec![
2079                (50.0, 5.0),
2080                (65.0, 15.0),
2081                (77.0, 18.0),
2082                (80.0, 32.0),
2083                (95.0, 50.0),
2084                (80.0, 68.0),
2085                (77.0, 82.0),
2086                (65.0, 85.0),
2087                (50.0, 95.0),
2088                (35.0, 85.0),
2089                (23.0, 82.0),
2090                (20.0, 68.0),
2091                (5.0, 50.0),
2092                (20.0, 32.0),
2093                (23.0, 18.0),
2094                (35.0, 15.0),
2095            ])
2096            .with_quantity(25); // Many pieces
2097
2098        let boundary = Boundary2D::rectangle(500.0, 500.0);
2099        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2100        let nester = Nester2D::new(config);
2101
2102        let result = nester
2103            .solve(std::slice::from_ref(&gear), &boundary)
2104            .unwrap();
2105
2106        println!("Placed {} pieces", result.placements.len());
2107
2108        // Check all placements
2109        for (i, p) in result.placements.iter().enumerate() {
2110            let origin_x = p.position[0];
2111            let origin_y = p.position[1];
2112            let rotation = p.rotation.first().copied().unwrap_or(0.0);
2113
2114            let (g_min_r, g_max_r) = gear.aabb_at_rotation(rotation);
2115
2116            let actual_min_x = origin_x + g_min_r[0];
2117            let actual_max_x = origin_x + g_max_r[0];
2118            let actual_min_y = origin_y + g_min_r[1];
2119            let actual_max_y = origin_y + g_max_r[1];
2120
2121            println!(
2122                "Piece {}: origin=({:.1}, {:.1}), rot={:.2}, bounds=[{:.1},{:.1}]x[{:.1},{:.1}]",
2123                i,
2124                origin_x,
2125                origin_y,
2126                rotation,
2127                actual_min_x,
2128                actual_max_x,
2129                actual_min_y,
2130                actual_max_y
2131            );
2132
2133            assert!(
2134                actual_max_x <= 500.01,
2135                "Piece {}: Right edge {} > 500",
2136                i,
2137                actual_max_x
2138            );
2139            assert!(
2140                actual_max_y <= 500.01,
2141                "Piece {}: Top edge {} > 500",
2142                i,
2143                actual_max_y
2144            );
2145        }
2146    }
2147
2148    #[test]
2149    fn test_blf_bounds_multi_strip() {
2150        // Test with solve_multi_strip which is what benchmark runner uses
2151        let gear = Geometry2D::new("gear")
2152            .with_polygon(vec![
2153                (50.0, 5.0),
2154                (65.0, 15.0),
2155                (77.0, 18.0),
2156                (80.0, 32.0),
2157                (95.0, 50.0),
2158                (80.0, 68.0),
2159                (77.0, 82.0),
2160                (65.0, 85.0),
2161                (50.0, 95.0),
2162                (35.0, 85.0),
2163                (23.0, 82.0),
2164                (20.0, 68.0),
2165                (5.0, 50.0),
2166                (20.0, 32.0),
2167                (23.0, 18.0),
2168                (35.0, 15.0),
2169            ])
2170            .with_quantity(50); // Many pieces to force multiple strips
2171
2172        let boundary = Boundary2D::rectangle(500.0, 500.0);
2173        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2174        let nester = Nester2D::new(config);
2175
2176        // Use solve_multi_strip like benchmark runner does
2177        let result = nester
2178            .solve_multi_strip(std::slice::from_ref(&gear), &boundary)
2179            .unwrap();
2180
2181        println!(
2182            "Placed {} pieces across {} strips",
2183            result.placements.len(),
2184            result.boundaries_used
2185        );
2186
2187        // Check all placements - within their respective strips
2188        let strip_width = 500.0;
2189        for (i, p) in result.placements.iter().enumerate() {
2190            let origin_x = p.position[0];
2191            let origin_y = p.position[1];
2192            let rotation = p.rotation.first().copied().unwrap_or(0.0);
2193            let strip_idx = p.boundary_index;
2194
2195            // Calculate local position within strip
2196            let local_x = origin_x - (strip_idx as f64 * strip_width);
2197
2198            let (_g_min_r, g_max_r) = gear.aabb_at_rotation(rotation);
2199
2200            let local_max_x = local_x + g_max_r[0];
2201            let local_max_y = origin_y + g_max_r[1];
2202
2203            println!(
2204                "Piece {}: strip={}, origin=({:.1}, {:.1}), local_x={:.1}, rot={:.2}, local_max_x={:.1}",
2205                i, strip_idx, origin_x, origin_y, local_x, rotation, local_max_x
2206            );
2207
2208            assert!(
2209                local_max_x <= 500.01,
2210                "Piece {}: In strip {}, local right edge {:.1} > 500",
2211                i,
2212                strip_idx,
2213                local_max_x
2214            );
2215            assert!(
2216                local_max_y <= 500.01,
2217                "Piece {}: Top edge {:.1} > 500",
2218                i,
2219                local_max_y
2220            );
2221        }
2222    }
2223
2224    #[test]
2225    fn test_blf_bounds_mixed_shapes() {
2226        // Replicate test_blf.py with all 9 shapes
2227        let shapes = vec![
2228            // Shape 0: Rounded rectangle (demand 2)
2229            Geometry2D::new("shape0")
2230                .with_polygon(vec![
2231                    (0.0, 0.0),
2232                    (180.0, 0.0),
2233                    (195.0, 15.0),
2234                    (200.0, 50.0),
2235                    (200.0, 150.0),
2236                    (195.0, 185.0),
2237                    (180.0, 200.0),
2238                    (20.0, 200.0),
2239                    (5.0, 185.0),
2240                    (0.0, 150.0),
2241                    (0.0, 50.0),
2242                    (5.0, 15.0),
2243                ])
2244                .with_quantity(2),
2245            // Shape 1: Circular-ish (demand 4)
2246            Geometry2D::new("shape1")
2247                .with_polygon(vec![
2248                    (60.0, 0.0),
2249                    (85.0, 7.0),
2250                    (104.0, 25.0),
2251                    (118.0, 50.0),
2252                    (120.0, 60.0),
2253                    (118.0, 70.0),
2254                    (104.0, 95.0),
2255                    (85.0, 113.0),
2256                    (60.0, 120.0),
2257                    (35.0, 113.0),
2258                    (16.0, 95.0),
2259                    (2.0, 70.0),
2260                    (0.0, 60.0),
2261                    (2.0, 50.0),
2262                    (16.0, 25.0),
2263                    (35.0, 7.0),
2264                ])
2265                .with_quantity(4),
2266            // Shape 2: L-shape (demand 6)
2267            Geometry2D::new("shape2")
2268                .with_polygon(vec![
2269                    (0.0, 0.0),
2270                    (80.0, 0.0),
2271                    (80.0, 20.0),
2272                    (20.0, 20.0),
2273                    (20.0, 80.0),
2274                    (0.0, 80.0),
2275                ])
2276                .with_quantity(6),
2277            // Shape 3: Triangle (demand 6)
2278            Geometry2D::new("shape3")
2279                .with_polygon(vec![(0.0, 0.0), (70.0, 0.0), (0.0, 70.0)])
2280                .with_quantity(6),
2281            // Shape 4: Rectangle (demand 4)
2282            Geometry2D::new("shape4")
2283                .with_polygon(vec![(0.0, 0.0), (120.0, 0.0), (120.0, 60.0), (0.0, 60.0)])
2284                .with_quantity(4),
2285            // Shape 5: Hexagon (demand 8)
2286            Geometry2D::new("shape5")
2287                .with_polygon(vec![
2288                    (15.0, 0.0),
2289                    (45.0, 0.0),
2290                    (60.0, 26.0),
2291                    (45.0, 52.0),
2292                    (15.0, 52.0),
2293                    (0.0, 26.0),
2294                ])
2295                .with_quantity(8),
2296            // Shape 6: T-shape (demand 4)
2297            Geometry2D::new("shape6")
2298                .with_polygon(vec![
2299                    (0.0, 0.0),
2300                    (90.0, 0.0),
2301                    (90.0, 12.0),
2302                    (55.0, 12.0),
2303                    (55.0, 60.0),
2304                    (35.0, 60.0),
2305                    (35.0, 12.0),
2306                    (0.0, 12.0),
2307                ])
2308                .with_quantity(4),
2309            // Shape 7: Rounded square (demand 3)
2310            Geometry2D::new("shape7")
2311                .with_polygon(vec![
2312                    (0.0, 10.0),
2313                    (10.0, 0.0),
2314                    (70.0, 0.0),
2315                    (80.0, 10.0),
2316                    (80.0, 70.0),
2317                    (70.0, 80.0),
2318                    (10.0, 80.0),
2319                    (0.0, 70.0),
2320                ])
2321                .with_quantity(3),
2322            // Shape 8: Gear (demand 13) - the problematic shape
2323            Geometry2D::new("shape8_gear")
2324                .with_polygon(vec![
2325                    (50.0, 5.0),
2326                    (65.0, 15.0),
2327                    (77.0, 18.0),
2328                    (80.0, 32.0),
2329                    (95.0, 50.0),
2330                    (80.0, 68.0),
2331                    (77.0, 82.0),
2332                    (65.0, 85.0),
2333                    (50.0, 95.0),
2334                    (35.0, 85.0),
2335                    (23.0, 82.0),
2336                    (20.0, 68.0),
2337                    (5.0, 50.0),
2338                    (20.0, 32.0),
2339                    (23.0, 18.0),
2340                    (35.0, 15.0),
2341                ])
2342                .with_quantity(13),
2343        ];
2344
2345        // Total: 2+4+6+6+4+8+4+3+13 = 50 pieces
2346        let boundary = Boundary2D::rectangle(500.0, 500.0);
2347        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2348        let nester = Nester2D::new(config);
2349
2350        let result = nester.solve_multi_strip(&shapes, &boundary).unwrap();
2351
2352        println!(
2353            "Placed {} pieces across {} strips",
2354            result.placements.len(),
2355            result.boundaries_used
2356        );
2357
2358        // Check placements for Gear (shape8) specifically
2359        let strip_width = 500.0;
2360        let gear_aabb = shapes[8].aabb();
2361        println!("Gear AABB: min={:?}, max={:?}", gear_aabb.0, gear_aabb.1);
2362
2363        let mut violations = Vec::new();
2364        for p in &result.placements {
2365            if p.geometry_id.as_str().starts_with("shape8") {
2366                let origin_x = p.position[0];
2367                let _origin_y = p.position[1];
2368                let rotation = p.rotation.first().copied().unwrap_or(0.0);
2369                let strip_idx = p.boundary_index;
2370                let local_x = origin_x - (strip_idx as f64 * strip_width);
2371
2372                let (_g_min_r, g_max_r) = shapes[8].aabb_at_rotation(rotation);
2373                let local_max_x = local_x + g_max_r[0];
2374
2375                println!(
2376                    "{}: strip={}, local_x={:.1}, rot={:.2}, local_max_x={:.1}",
2377                    p.geometry_id, strip_idx, local_x, rotation, local_max_x
2378                );
2379
2380                if local_max_x > 500.01 {
2381                    violations.push((p.geometry_id.clone(), strip_idx, local_x, local_max_x));
2382                }
2383            }
2384        }
2385
2386        assert!(
2387            violations.is_empty(),
2388            "Found {} Gear pieces exceeding boundary: {:?}",
2389            violations.len(),
2390            violations
2391        );
2392    }
2393
2394    #[test]
2395    fn test_blf_bounds_expanded_like_benchmark() {
2396        // Replicate EXACTLY how benchmark runner creates geometries:
2397        // Each piece is a separate Geometry2D with quantity=1
2398        // (vertices, demand, allowed_rotations_deg)
2399        type ShapeDef = (Vec<(f64, f64)>, usize, Vec<f64>);
2400        let shape_defs: Vec<ShapeDef> = vec![
2401            (
2402                vec![
2403                    (0.0, 0.0),
2404                    (180.0, 0.0),
2405                    (195.0, 15.0),
2406                    (200.0, 50.0),
2407                    (200.0, 150.0),
2408                    (195.0, 185.0),
2409                    (180.0, 200.0),
2410                    (20.0, 200.0),
2411                    (5.0, 185.0),
2412                    (0.0, 150.0),
2413                    (0.0, 50.0),
2414                    (5.0, 15.0),
2415                ],
2416                2,
2417                vec![0.0, 90.0, 180.0, 270.0],
2418            ),
2419            (
2420                vec![
2421                    (60.0, 0.0),
2422                    (85.0, 7.0),
2423                    (104.0, 25.0),
2424                    (118.0, 50.0),
2425                    (120.0, 60.0),
2426                    (118.0, 70.0),
2427                    (104.0, 95.0),
2428                    (85.0, 113.0),
2429                    (60.0, 120.0),
2430                    (35.0, 113.0),
2431                    (16.0, 95.0),
2432                    (2.0, 70.0),
2433                    (0.0, 60.0),
2434                    (2.0, 50.0),
2435                    (16.0, 25.0),
2436                    (35.0, 7.0),
2437                ],
2438                4,
2439                vec![0.0, 45.0, 90.0, 135.0],
2440            ),
2441            (
2442                vec![
2443                    (0.0, 0.0),
2444                    (80.0, 0.0),
2445                    (80.0, 20.0),
2446                    (20.0, 20.0),
2447                    (20.0, 80.0),
2448                    (0.0, 80.0),
2449                ],
2450                6,
2451                vec![0.0, 90.0, 180.0, 270.0],
2452            ),
2453            (
2454                vec![(0.0, 0.0), (70.0, 0.0), (0.0, 70.0)],
2455                6,
2456                vec![0.0, 90.0, 180.0, 270.0],
2457            ),
2458            (
2459                vec![(0.0, 0.0), (120.0, 0.0), (120.0, 60.0), (0.0, 60.0)],
2460                4,
2461                vec![0.0, 90.0],
2462            ),
2463            (
2464                vec![
2465                    (15.0, 0.0),
2466                    (45.0, 0.0),
2467                    (60.0, 26.0),
2468                    (45.0, 52.0),
2469                    (15.0, 52.0),
2470                    (0.0, 26.0),
2471                ],
2472                8,
2473                vec![0.0, 60.0, 120.0],
2474            ),
2475            (
2476                vec![
2477                    (0.0, 0.0),
2478                    (90.0, 0.0),
2479                    (90.0, 12.0),
2480                    (55.0, 12.0),
2481                    (55.0, 60.0),
2482                    (35.0, 60.0),
2483                    (35.0, 12.0),
2484                    (0.0, 12.0),
2485                ],
2486                4,
2487                vec![0.0, 90.0, 180.0, 270.0],
2488            ),
2489            (
2490                vec![
2491                    (0.0, 10.0),
2492                    (10.0, 0.0),
2493                    (70.0, 0.0),
2494                    (80.0, 10.0),
2495                    (80.0, 70.0),
2496                    (70.0, 80.0),
2497                    (10.0, 80.0),
2498                    (0.0, 70.0),
2499                ],
2500                3,
2501                vec![0.0, 90.0],
2502            ),
2503            // Shape 8: Gear - with all 8 rotations
2504            (
2505                vec![
2506                    (50.0, 5.0),
2507                    (65.0, 15.0),
2508                    (77.0, 18.0),
2509                    (80.0, 32.0),
2510                    (95.0, 50.0),
2511                    (80.0, 68.0),
2512                    (77.0, 82.0),
2513                    (65.0, 85.0),
2514                    (50.0, 95.0),
2515                    (35.0, 85.0),
2516                    (23.0, 82.0),
2517                    (20.0, 68.0),
2518                    (5.0, 50.0),
2519                    (20.0, 32.0),
2520                    (23.0, 18.0),
2521                    (35.0, 15.0),
2522                ],
2523                13,
2524                vec![0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0],
2525            ),
2526        ];
2527
2528        // Expand like benchmark runner: each piece is separate geometry
2529        let mut geometries = Vec::new();
2530        let mut piece_id = 0;
2531        for (vertices, demand, rotations) in shape_defs.iter() {
2532            for _ in 0..*demand {
2533                let geom = Geometry2D::new(format!("piece_{}", piece_id))
2534                    .with_polygon(vertices.clone())
2535                    .with_rotations_deg(rotations.clone());
2536                geometries.push(geom);
2537                piece_id += 1;
2538            }
2539        }
2540
2541        // Store gear AABB for checking
2542        let gear_geom = Geometry2D::new("gear_check").with_polygon(shape_defs[8].0.clone());
2543        let (gear_min, gear_max) = gear_geom.aabb();
2544        println!("Gear AABB: min={:?}, max={:?}", gear_min, gear_max);
2545
2546        let boundary = Boundary2D::rectangle(500.0, 500.0);
2547        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2548        let nester = Nester2D::new(config);
2549
2550        let result = nester.solve_multi_strip(&geometries, &boundary).unwrap();
2551
2552        println!(
2553            "Placed {} pieces across {} strips",
2554            result.placements.len(),
2555            result.boundaries_used
2556        );
2557
2558        // Check Gear placements (piece_37 to piece_49)
2559        let strip_width = 500.0;
2560        let mut violations = Vec::new();
2561
2562        for p in &result.placements {
2563            let id_num: usize = p
2564                .geometry_id
2565                .as_str()
2566                .strip_prefix("piece_")
2567                .and_then(|s| s.parse().ok())
2568                .unwrap_or(0);
2569
2570            // piece_37 to piece_49 are Gear shapes
2571            if (37..=49).contains(&id_num) {
2572                let origin_x = p.position[0];
2573                let rotation = p.rotation.first().copied().unwrap_or(0.0);
2574                let strip_idx = p.boundary_index;
2575                let local_x = origin_x - (strip_idx as f64 * strip_width);
2576
2577                let (_, g_max_r) = gear_geom.aabb_at_rotation(rotation);
2578                let local_max_x = local_x + g_max_r[0];
2579
2580                println!(
2581                    "{}: strip={}, local_x={:.1}, rot={:.2}, local_max_x={:.1}",
2582                    p.geometry_id, strip_idx, local_x, rotation, local_max_x
2583                );
2584
2585                if local_max_x > 500.01 {
2586                    violations.push((p.geometry_id.clone(), strip_idx, local_x, local_max_x));
2587                }
2588            }
2589        }
2590
2591        assert!(
2592            violations.is_empty(),
2593            "Found {} Gear pieces exceeding boundary: {:?}",
2594            violations.len(),
2595            violations
2596        );
2597    }
2598
2599    /// Helper function to check if two AABBs overlap
2600    fn aabbs_overlap(
2601        a_min: [f64; 2],
2602        a_max: [f64; 2],
2603        b_min: [f64; 2],
2604        b_max: [f64; 2],
2605        tolerance: f64,
2606    ) -> bool {
2607        // Two AABBs overlap if they overlap on both axes
2608        let x_overlap = a_min[0] < b_max[0] - tolerance && a_max[0] > b_min[0] + tolerance;
2609        let y_overlap = a_min[1] < b_max[1] - tolerance && a_max[1] > b_min[1] + tolerance;
2610        x_overlap && y_overlap
2611    }
2612
2613    /// Comprehensive test for all strategies - checks boundary and overlap violations
2614    #[test]
2615    fn test_all_strategies_boundary_and_overlap() {
2616        use std::collections::HashMap;
2617
2618        // Create test shapes similar to demo
2619        let shapes = vec![
2620            Geometry2D::new("shape0")
2621                .with_polygon(vec![
2622                    (0.0, 0.0),
2623                    (180.0, 0.0),
2624                    (195.0, 15.0),
2625                    (200.0, 50.0),
2626                    (200.0, 150.0),
2627                    (195.0, 185.0),
2628                    (180.0, 200.0),
2629                    (20.0, 200.0),
2630                    (5.0, 185.0),
2631                    (0.0, 150.0),
2632                    (0.0, 50.0),
2633                    (5.0, 15.0),
2634                ])
2635                .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
2636                .with_quantity(2),
2637            Geometry2D::new("shape1_flange")
2638                .with_polygon(vec![
2639                    (60.0, 0.0),
2640                    (85.0, 7.0),
2641                    (104.0, 25.0),
2642                    (118.0, 50.0),
2643                    (120.0, 60.0),
2644                    (118.0, 70.0),
2645                    (104.0, 95.0),
2646                    (85.0, 113.0),
2647                    (60.0, 120.0),
2648                    (35.0, 113.0),
2649                    (16.0, 95.0),
2650                    (2.0, 70.0),
2651                    (0.0, 60.0),
2652                    (2.0, 50.0),
2653                    (16.0, 25.0),
2654                    (35.0, 7.0),
2655                ])
2656                .with_rotations_deg(vec![0.0, 45.0, 90.0, 135.0])
2657                .with_quantity(4),
2658            Geometry2D::new("shape2_lbracket")
2659                .with_polygon(vec![
2660                    (0.0, 0.0),
2661                    (80.0, 0.0),
2662                    (80.0, 20.0),
2663                    (20.0, 20.0),
2664                    (20.0, 80.0),
2665                    (0.0, 80.0),
2666                ])
2667                .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
2668                .with_quantity(6),
2669            Geometry2D::new("shape3_triangle")
2670                .with_polygon(vec![(0.0, 0.0), (70.0, 0.0), (0.0, 70.0)])
2671                .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
2672                .with_quantity(6),
2673            Geometry2D::new("shape4_rect")
2674                .with_polygon(vec![(0.0, 0.0), (120.0, 0.0), (120.0, 60.0), (0.0, 60.0)])
2675                .with_rotations_deg(vec![0.0, 90.0])
2676                .with_quantity(4),
2677            Geometry2D::new("shape5_hexagon")
2678                .with_polygon(vec![
2679                    (15.0, 0.0),
2680                    (45.0, 0.0),
2681                    (60.0, 26.0),
2682                    (45.0, 52.0),
2683                    (15.0, 52.0),
2684                    (0.0, 26.0),
2685                ])
2686                .with_rotations_deg(vec![0.0, 60.0, 120.0])
2687                .with_quantity(8),
2688            Geometry2D::new("shape6_tstiff")
2689                .with_polygon(vec![
2690                    (0.0, 0.0),
2691                    (90.0, 0.0),
2692                    (90.0, 12.0),
2693                    (55.0, 12.0),
2694                    (55.0, 60.0),
2695                    (35.0, 60.0),
2696                    (35.0, 12.0),
2697                    (0.0, 12.0),
2698                ])
2699                .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
2700                .with_quantity(4),
2701            Geometry2D::new("shape7_mount")
2702                .with_polygon(vec![
2703                    (0.0, 10.0),
2704                    (10.0, 0.0),
2705                    (70.0, 0.0),
2706                    (80.0, 10.0),
2707                    (80.0, 70.0),
2708                    (70.0, 80.0),
2709                    (10.0, 80.0),
2710                    (0.0, 70.0),
2711                ])
2712                .with_rotations_deg(vec![0.0, 90.0])
2713                .with_quantity(3),
2714            Geometry2D::new("shape8_gear")
2715                .with_polygon(vec![
2716                    (50.0, 5.0),
2717                    (65.0, 15.0),
2718                    (77.0, 18.0),
2719                    (80.0, 32.0),
2720                    (95.0, 50.0),
2721                    (80.0, 68.0),
2722                    (77.0, 82.0),
2723                    (65.0, 85.0),
2724                    (50.0, 95.0),
2725                    (35.0, 85.0),
2726                    (23.0, 82.0),
2727                    (20.0, 68.0),
2728                    (5.0, 50.0),
2729                    (20.0, 32.0),
2730                    (23.0, 18.0),
2731                    (35.0, 15.0),
2732                ])
2733                .with_rotations_deg(vec![0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0])
2734                .with_quantity(13),
2735        ];
2736
2737        // Build geometry lookup map
2738        let geom_map: HashMap<String, &Geometry2D> =
2739            shapes.iter().map(|g| (g.id().clone(), g)).collect();
2740
2741        let boundary = Boundary2D::rectangle(500.0, 500.0);
2742        let strip_width = 500.0;
2743
2744        // Test each strategy
2745        let strategies = vec![
2746            Strategy::BottomLeftFill,
2747            Strategy::NfpGuided,
2748            Strategy::GeneticAlgorithm,
2749            Strategy::Brkga,
2750            Strategy::SimulatedAnnealing,
2751            Strategy::Gdrr,
2752            Strategy::Alns,
2753        ];
2754
2755        for strategy in strategies {
2756            println!("\n========== Testing {:?} ==========", strategy);
2757
2758            let config = Config::default()
2759                .with_strategy(strategy)
2760                .with_time_limit(30000); // 30s max per strategy
2761            let nester = Nester2D::new(config);
2762
2763            let result = match nester.solve_multi_strip(&shapes, &boundary) {
2764                Ok(r) => r,
2765                Err(e) => {
2766                    println!("  Strategy {:?} failed: {}", strategy, e);
2767                    continue;
2768                }
2769            };
2770
2771            println!(
2772                "  Placed {} pieces across {} strips",
2773                result.placements.len(),
2774                result.boundaries_used
2775            );
2776
2777            // Check 1: Boundary violations
2778            let mut boundary_violations = Vec::new();
2779            for p in &result.placements {
2780                // Find the base geometry ID (without instance suffix)
2781                let base_id = p.geometry_id.split('_').next().unwrap_or(&p.geometry_id);
2782                let full_id = if base_id.starts_with("shape") {
2783                    // Find matching geometry by checking all shape IDs
2784                    shapes
2785                        .iter()
2786                        .find(|g| p.geometry_id.starts_with(g.id()))
2787                        .map(|g| g.id().as_str())
2788                } else {
2789                    geom_map.get(&p.geometry_id).map(|g| g.id().as_str())
2790                };
2791
2792                let geom = match full_id.and_then(|id| geom_map.get(id)) {
2793                    Some(g) => *g,
2794                    None => {
2795                        // Try to find by prefix match
2796                        match shapes.iter().find(|g| p.geometry_id.starts_with(g.id())) {
2797                            Some(g) => g,
2798                            None => {
2799                                println!(
2800                                    "  WARNING: Could not find geometry for {}",
2801                                    p.geometry_id
2802                                );
2803                                continue;
2804                            }
2805                        }
2806                    }
2807                };
2808
2809                let origin_x = p.position[0];
2810                let origin_y = p.position[1];
2811                let rotation = p.rotation.first().copied().unwrap_or(0.0);
2812                let strip_idx = p.boundary_index;
2813
2814                // Calculate local position within strip
2815                let local_x = origin_x - (strip_idx as f64 * strip_width);
2816
2817                let (g_min, g_max) = geom.aabb_at_rotation(rotation);
2818
2819                // Calculate actual bounds in local strip coordinates
2820                let local_min_x = local_x + g_min[0];
2821                let local_max_x = local_x + g_max[0];
2822                let local_min_y = origin_y + g_min[1];
2823                let local_max_y = origin_y + g_max[1];
2824
2825                // Check boundary (with small tolerance)
2826                let tolerance = 0.1;
2827                if local_min_x < -tolerance
2828                    || local_max_x > 500.0 + tolerance
2829                    || local_min_y < -tolerance
2830                    || local_max_y > 500.0 + tolerance
2831                {
2832                    boundary_violations.push(format!(
2833                        "{} in strip {}: bounds ({:.1}, {:.1}) to ({:.1}, {:.1})",
2834                        p.geometry_id,
2835                        strip_idx,
2836                        local_min_x,
2837                        local_min_y,
2838                        local_max_x,
2839                        local_max_y
2840                    ));
2841                }
2842            }
2843
2844            if !boundary_violations.is_empty() {
2845                println!("  BOUNDARY VIOLATIONS ({}):", boundary_violations.len());
2846                for v in &boundary_violations {
2847                    println!("    - {}", v);
2848                }
2849            }
2850
2851            // Check 2: Overlaps (within same strip)
2852            let mut overlaps = Vec::new();
2853            let placements: Vec<_> = result.placements.iter().collect();
2854
2855            for i in 0..placements.len() {
2856                for j in (i + 1)..placements.len() {
2857                    let p1 = placements[i];
2858                    let p2 = placements[j];
2859
2860                    // Only check overlaps within the same strip
2861                    if p1.boundary_index != p2.boundary_index {
2862                        continue;
2863                    }
2864
2865                    // Find geometries
2866                    let g1 = shapes.iter().find(|g| p1.geometry_id.starts_with(g.id()));
2867                    let g2 = shapes.iter().find(|g| p2.geometry_id.starts_with(g.id()));
2868
2869                    let (g1, g2) = match (g1, g2) {
2870                        (Some(a), Some(b)) => (a, b),
2871                        _ => continue,
2872                    };
2873
2874                    let strip_idx = p1.boundary_index;
2875                    let local_x1 = p1.position[0] - (strip_idx as f64 * strip_width);
2876                    let local_x2 = p2.position[0] - (strip_idx as f64 * strip_width);
2877
2878                    let rot1 = p1.rotation.first().copied().unwrap_or(0.0);
2879                    let rot2 = p2.rotation.first().copied().unwrap_or(0.0);
2880
2881                    let (g1_min, g1_max) = g1.aabb_at_rotation(rot1);
2882                    let (g2_min, g2_max) = g2.aabb_at_rotation(rot2);
2883
2884                    let a_min = [local_x1 + g1_min[0], p1.position[1] + g1_min[1]];
2885                    let a_max = [local_x1 + g1_max[0], p1.position[1] + g1_max[1]];
2886                    let b_min = [local_x2 + g2_min[0], p2.position[1] + g2_min[1]];
2887                    let b_max = [local_x2 + g2_max[0], p2.position[1] + g2_max[1]];
2888
2889                    if aabbs_overlap(a_min, a_max, b_min, b_max, 1.0) {
2890                        overlaps.push(format!(
2891                            "{} and {} in strip {}",
2892                            p1.geometry_id, p2.geometry_id, strip_idx
2893                        ));
2894                    }
2895                }
2896            }
2897
2898            if !overlaps.is_empty() {
2899                println!("  OVERLAPS ({}):", overlaps.len());
2900                for o in overlaps.iter().take(10) {
2901                    println!("    - {}", o);
2902                }
2903                if overlaps.len() > 10 {
2904                    println!("    ... and {} more", overlaps.len() - 10);
2905                }
2906            }
2907
2908            // Assert no boundary violations
2909            assert!(
2910                boundary_violations.is_empty(),
2911                "{:?}: Found {} boundary violations",
2912                strategy,
2913                boundary_violations.len()
2914            );
2915
2916            println!("  ✓ All placements within boundary");
2917            println!("  ✓ No AABB overlaps detected");
2918        }
2919    }
2920
2921    /// Regression guard for the multi-strip overflow distribution (ISSUE-20260621b).
2922    ///
2923    /// A single geometry with quantity 20 (100×100) cannot fit in one 300×300 sheet
2924    /// (9 per sheet). The prior id-level `retain` dropped a geometry from `remaining`
2925    /// as soon as *any* instance was placed, silently losing the rest. The fixed
2926    /// instance-level reduction must spread all 20 across sheets: 9 + 9 + 2 = 3 sheets,
2927    /// 0 unplaced, and every `(geometry_id, instance)` pair unique across all sheets.
2928    #[test]
2929    fn test_multi_strip_distributes_all_instances() {
2930        let geometries = vec![Geometry2D::rectangle("part", 100.0, 100.0).with_quantity(20)];
2931        let boundary = Boundary2D::rectangle(300.0, 300.0);
2932        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2933        let nester = Nester2D::new(config);
2934
2935        let result = nester.solve_multi_strip(&geometries, &boundary).unwrap();
2936
2937        // All 20 instances placed across exactly 3 sheets (9 + 9 + 2), none unplaced.
2938        assert_eq!(
2939            result.placements.len(),
2940            20,
2941            "all 20 instances must be placed"
2942        );
2943        assert_eq!(
2944            result.boundaries_used, 3,
2945            "20 of 100x100 in 300x300 => 3 sheets"
2946        );
2947        assert!(
2948            result.unplaced.is_empty(),
2949            "nothing should be unplaced, got {:?}",
2950            result.unplaced
2951        );
2952        // Instance-level request total is recorded (mirrors single-strip solve()).
2953        assert_eq!(result.total_requested, 20);
2954
2955        // Every (geometry_id, instance) pair is globally unique — strips re-number
2956        // from 0 internally, so the multi-strip path must reassign global indices.
2957        let mut seen = std::collections::HashSet::new();
2958        for p in &result.placements {
2959            assert!(
2960                seen.insert((p.geometry_id.clone(), p.instance)),
2961                "duplicate (id, instance) = ({}, {}) across sheets",
2962                p.geometry_id,
2963                p.instance
2964            );
2965        }
2966        // Sheet indices are contiguous 0..3.
2967        let mut sheets: Vec<usize> = result.placements.iter().map(|p| p.boundary_index).collect();
2968        sheets.sort_unstable();
2969        sheets.dedup();
2970        assert_eq!(sheets, vec![0, 1, 2]);
2971    }
2972
2973    /// When an item is genuinely too large for the sheet, the after-loop sweep must
2974    /// report it as unplaced (instance-level) rather than silently dropping it.
2975    #[test]
2976    fn test_multi_strip_oversized_reported_unplaced() {
2977        let geometries = vec![
2978            Geometry2D::rectangle("ok", 50.0, 50.0).with_quantity(2),
2979            Geometry2D::rectangle("toobig", 400.0, 400.0).with_quantity(3),
2980        ];
2981        let boundary = Boundary2D::rectangle(300.0, 300.0);
2982        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2983        let nester = Nester2D::new(config);
2984
2985        let result = nester.solve_multi_strip(&geometries, &boundary).unwrap();
2986
2987        assert_eq!(result.total_requested, 5, "2 + 3 instances requested");
2988        // The two 50x50 fit; the oversized geometry is reported unplaced (deduped id).
2989        assert_eq!(result.placements.len(), 2);
2990        assert!(
2991            result.unplaced.contains(&"toobig".to_string()),
2992            "oversized geometry must surface in unplaced, got {:?}",
2993            result.unplaced
2994        );
2995    }
2996}