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