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