Skip to main content

u_nesting_d2/
nester.rs

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