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