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