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