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