Skip to main content

u_nesting_d2/
nester.rs

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