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