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