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