Skip to main content

u_nesting_d2/
nester.rs

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