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