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