Skip to main content

u_nesting_core/
solver.rs

1//! Solver traits and configuration.
2
3use crate::geometry::{Boundary, Geometry};
4use crate::result::SolveResult;
5use crate::Result;
6
7#[cfg(feature = "serde")]
8use serde::{Deserialize, Serialize};
9
10/// Optimization strategy.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
13pub enum Strategy {
14    /// Bottom-Left Fill (fast, lower quality).
15    #[default]
16    BottomLeftFill,
17    /// NFP-guided placement (balanced).
18    NfpGuided,
19    /// Genetic Algorithm (slower, higher quality).
20    GeneticAlgorithm,
21    /// Biased Random-Key Genetic Algorithm (balanced, robust).
22    Brkga,
23    /// Simulated Annealing.
24    SimulatedAnnealing,
25    /// Extreme Point heuristic (3D only).
26    ExtremePoint,
27    /// Goal-Driven Ruin and Recreate (GDRR).
28    Gdrr,
29    /// Adaptive Large Neighborhood Search (ALNS).
30    Alns,
31    /// MILP-based exact solver (small instances, optimal solution).
32    MilpExact,
33    /// Hybrid: Try exact first, fallback to heuristic on timeout.
34    HybridExact,
35}
36
37/// The two problem families a strategy runs on: 2D nesting and 3D packing.
38/// On the wire (a request's `mode`, the strategy catalog) `"2d"` / `"3d"`.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
41pub enum Dimension {
42    /// 2D nesting of parts on sheets.
43    #[cfg_attr(feature = "serde", serde(rename = "2d"))]
44    TwoD,
45    /// 3D packing of boxes into bins.
46    #[cfg_attr(feature = "serde", serde(rename = "3d"))]
47    ThreeD,
48}
49
50impl Dimension {
51    /// The dimension's name on the wire: `"2d"` or `"3d"`.
52    pub fn key(self) -> &'static str {
53        match self {
54            Self::TwoD => "2d",
55            Self::ThreeD => "3d",
56        }
57    }
58}
59
60/// The settings a request's `config` can carry besides `strategy`, in the
61/// order of `ConfigRequest::KEYS`. Which of them a strategy reads is
62/// [`Strategy::reads`].
63pub const SETTINGS: [&str; 11] = [
64    "spacing",
65    "margin",
66    "time_limit_ms",
67    "target_utilization",
68    "population_size",
69    "max_generations",
70    "max_iterations",
71    "crossover_rate",
72    "mutation_rate",
73    "seed",
74    "multi_sheet",
75];
76
77/// The short names of the strategies `dimension` offers in this build, joined
78/// for a message: `"blf, nfp, ga, ..."` -- what a refusal of a strategy the
79/// dimension does not offer lists.
80pub fn offered_names(dimension: Dimension) -> String {
81    Strategy::offered(dimension)
82        .into_iter()
83        .map(Strategy::name)
84        .collect::<Vec<_>>()
85        .join(", ")
86}
87
88impl Strategy {
89    /// Every strategy, in the order of [`Strategy::NAMES`].
90    pub const ALL: [Self; 10] = [
91        Self::BottomLeftFill,
92        Self::NfpGuided,
93        Self::GeneticAlgorithm,
94        Self::Brkga,
95        Self::SimulatedAnnealing,
96        Self::ExtremePoint,
97        Self::Gdrr,
98        Self::Alns,
99        Self::MilpExact,
100        Self::HybridExact,
101    ];
102
103    /// The strategy's short name -- its entry in [`Strategy::NAMES`].
104    pub fn name(self) -> &'static str {
105        let index = Self::ALL
106            .iter()
107            .position(|&s| s == self)
108            .expect("ALL lists every strategy");
109        Self::NAMES[index]
110    }
111
112    /// Whether `dimension` offers this strategy in this build. 2D nesting
113    /// offers every strategy but extreme point; the exact strategies only in a
114    /// build with the `milp` feature. 3D packing offers bottom-left (layer)
115    /// fill, extreme point and the three population/annealing searches.
116    pub fn is_offered(self, dimension: Dimension) -> bool {
117        use Strategy::*;
118        match dimension {
119            Dimension::TwoD => {
120                self != ExtremePoint
121                    && (cfg!(feature = "milp") || !matches!(self, MilpExact | HybridExact))
122            }
123            Dimension::ThreeD => matches!(
124                self,
125                BottomLeftFill | ExtremePoint | GeneticAlgorithm | Brkga | SimulatedAnnealing
126            ),
127        }
128    }
129
130    /// The strategies `dimension` offers in this build, in
131    /// [`Strategy::NAMES`] order.
132    pub fn offered(dimension: Dimension) -> Vec<Self> {
133        Self::ALL
134            .into_iter()
135            .filter(|s| s.is_offered(dimension))
136            .collect()
137    }
138
139    /// Whether this strategy, run in `dimension`, reads `setting` (one of
140    /// [`SETTINGS`]). This is the one table both the refusal of a setting the
141    /// strategy does not read (`ConfigRequest::to_config`) and the published
142    /// description of each strategy ([`Strategy::reads`]) come from.
143    ///
144    /// - `spacing`, `margin`, `time_limit_ms`: every strategy.
145    /// - `multi_sheet`: every 2D strategy (3D packing has no sheets).
146    /// - `crossover_rate`, `mutation_rate`: the genetic algorithm.
147    /// - `population_size`, `max_generations`: the two population searches.
148    /// - `max_iterations`: the single-solution searches (SA, GDRR, ALNS, and
149    ///   the hybrid's ALNS fallback).
150    /// - `seed`: the stochastic searches (and the hybrid's ALNS fallback).
151    /// - `target_utilization`: the searches that stop on a target (GA, BRKGA,
152    ///   SA).
153    ///
154    /// A name outside [`SETTINGS`] is read by nothing.
155    pub fn reads_setting(self, dimension: Dimension, setting: &str) -> bool {
156        use Strategy::*;
157        match setting {
158            "spacing" | "margin" | "time_limit_ms" => true,
159            "multi_sheet" => dimension == Dimension::TwoD,
160            "crossover_rate" | "mutation_rate" => matches!(self, GeneticAlgorithm),
161            "population_size" | "max_generations" => matches!(self, GeneticAlgorithm | Brkga),
162            "max_iterations" => matches!(self, SimulatedAnnealing | Gdrr | Alns | HybridExact),
163            "seed" => matches!(
164                self,
165                GeneticAlgorithm | Brkga | SimulatedAnnealing | Gdrr | Alns | HybridExact
166            ),
167            "target_utilization" => matches!(self, GeneticAlgorithm | Brkga | SimulatedAnnealing),
168            _ => false,
169        }
170    }
171
172    /// The settings this strategy reads in `dimension`, in [`SETTINGS`] order
173    /// (see [`Self::reads_setting`]).
174    pub fn reads(self, dimension: Dimension) -> Vec<&'static str> {
175        SETTINGS
176            .into_iter()
177            .filter(|s| self.reads_setting(dimension, s))
178            .collect()
179    }
180
181    /// The names of the strategies `dimension` offers that read `setting`.
182    pub fn readers_of(dimension: Dimension, setting: &str) -> Vec<&'static str> {
183        Self::offered(dimension)
184            .into_iter()
185            .filter(|s| s.reads_setting(dimension, setting))
186            .map(Self::name)
187            .collect()
188    }
189
190    /// The short name of each strategy -- what a refusal of an unknown name
191    /// lists as expected. [`Strategy::parse`] reads these and the longer
192    /// forms in [`Strategy::SPELLINGS`].
193    pub const NAMES: [&'static str; 10] = [
194        "blf",
195        "nfp",
196        "ga",
197        "brkga",
198        "sa",
199        "ep",
200        "gdrr",
201        "alns",
202        "milpexact",
203        "hybridexact",
204    ];
205
206    /// Every spelling [`Strategy::parse`] reads, with the strategy it names:
207    /// the [`Strategy::NAMES`] and their longer forms.
208    pub const SPELLINGS: [(&'static str, Self); 18] = [
209        ("blf", Self::BottomLeftFill),
210        ("bottomleftfill", Self::BottomLeftFill),
211        ("nfp", Self::NfpGuided),
212        ("nfpguided", Self::NfpGuided),
213        ("ga", Self::GeneticAlgorithm),
214        ("genetic", Self::GeneticAlgorithm),
215        ("geneticalgorithm", Self::GeneticAlgorithm),
216        ("brkga", Self::Brkga),
217        ("sa", Self::SimulatedAnnealing),
218        ("simulatedannealing", Self::SimulatedAnnealing),
219        ("ep", Self::ExtremePoint),
220        ("extremepoint", Self::ExtremePoint),
221        ("gdrr", Self::Gdrr),
222        ("alns", Self::Alns),
223        ("milpexact", Self::MilpExact),
224        ("exact", Self::MilpExact),
225        ("hybridexact", Self::HybridExact),
226        ("hybrid", Self::HybridExact),
227    ];
228
229    /// Parses a strategy name (case-insensitive, whitespace-trimmed).
230    ///
231    /// Returns `None` for unrecognized names so callers surface an explicit
232    /// error instead of silently falling back to a default strategy (which
233    /// hides typos from consumers). This is the single source of truth for
234    /// strategy-name parsing across all bindings (C FFI, Python, WASM).
235    pub fn parse(name: &str) -> Option<Self> {
236        let name = name.trim().to_lowercase();
237        Self::SPELLINGS
238            .iter()
239            .find(|(spelling, _)| *spelling == name)
240            .map(|&(_, strategy)| strategy)
241    }
242}
243
244/// Common configuration for solvers.
245///
246/// It holds no thread count: a build with the `parallel` feature runs its
247/// parallel work on the rayon pool the solve is called from, so
248/// `rayon::ThreadPool::install` around the solve (or `RAYON_NUM_THREADS`)
249/// bounds it; without the feature a solve runs on the calling thread.
250#[derive(Debug, Clone)]
251#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
252pub struct Config {
253    /// Optimization strategy.
254    pub strategy: Strategy,
255
256    /// Minimum distance between any two placed geometries. It does not apply
257    /// between a geometry and the boundary — that is `margin`.
258    pub spacing: f64,
259
260    /// Minimum distance between any placed geometry and the boundary edge.
261    pub margin: f64,
262
263    /// Maximum computation time in milliseconds (0 = unlimited).
264    ///
265    /// The search strategies check it between individual placements, so a solve
266    /// returns within the limit plus the time of the greedy bottom-left pass it is
267    /// compared against. The layout returned is the best one found in that time.
268    pub time_limit_ms: u64,
269
270    /// Target utilization (0.0 - 1.0). Solver stops if reached.
271    pub target_utilization: Option<f64>,
272
273    // GA-specific parameters
274    /// Population size for the population-based strategies (GA, BRKGA).
275    ///
276    /// `None` runs each strategy with the size it is tuned for -- 2D GA and
277    /// BRKGA 30, 3D GA 100, 3D BRKGA 50; `Some(n)` runs it with `n`, at least
278    /// 2. The other strategies have no population and do not read it.
279    pub population_size: Option<usize>,
280
281    /// Generation budget for the population-based strategies (GA, BRKGA).
282    ///
283    /// `None` runs each strategy with the budget it is tuned for -- 2D GA and
284    /// BRKGA 50, 3D GA 500, 3D BRKGA 100; `Some(n)` allows `n` generations (0
285    /// evaluates the first population only). A run also ends on the time
286    /// limit, on cancellation and when the best layout stops improving.
287    pub max_generations: Option<u32>,
288
289    /// Iteration budget for the single-solution searches (SA, GDRR, ALNS).
290    ///
291    /// `None` runs each with the budget it is tuned for -- 2D SA 500, GDRR
292    /// and ALNS 1000, 3D SA 10000; `Some(n)` allows `n` iterations (at least
293    /// one). A seeded run that ends on this budget, not on the clock, is
294    /// reproduced exactly (see [`Config::seed`]). A run also ends on the time
295    /// limit and on cancellation, and SA when it has cooled.
296    pub max_iterations: Option<u64>,
297
298    /// Crossover rate for GA (0.0 - 1.0).
299    pub crossover_rate: f64,
300
301    /// Mutation rate for GA (0.0 - 1.0).
302    pub mutation_rate: f64,
303
304    /// Elite count for GA.
305    pub elite_count: usize,
306
307    /// Optional RNG seed for reproducible runs of the stochastic strategies
308    /// (GA, BRKGA, SA, GDRR, ALNS -- 2D and 3D). `None` seeds from system
309    /// entropy (non-deterministic).
310    ///
311    /// A seeded run draws every random choice from the seed, so the same
312    /// request with the same seed returns the same layout **when the run ends
313    /// on its own budget** -- the generation or iteration count, convergence,
314    /// or the target utilization. A run cut short by the clock is not
315    /// reproduced: how far a search gets before `time_limit_ms` (or a
316    /// cancellation) stops it depends on the machine and its load. With
317    /// `time_limit_ms` 0 a search runs to its own budget (`max_generations`,
318    /// `max_iterations`), so a seeded unlimited run is always reproduced.
319    pub seed: Option<u64>,
320}
321
322impl Default for Config {
323    fn default() -> Self {
324        Self {
325            strategy: Strategy::default(),
326            spacing: 0.0,
327            margin: 0.0,
328            time_limit_ms: 30000,
329            target_utilization: None,
330            population_size: None,
331            max_generations: None,
332            max_iterations: None,
333            crossover_rate: 0.85,
334            mutation_rate: 0.05,
335            elite_count: 5,
336            seed: None,
337        }
338    }
339}
340
341impl Config {
342    /// Creates a new configuration with default values.
343    pub fn new() -> Self {
344        Self::default()
345    }
346
347    /// Checks the fields a caller sets against their ranges -- the builders
348    /// store what they are given, and every solver calls this before it runs.
349    ///
350    /// # Errors
351    /// `ConfigError` naming the first field out of range: `spacing` or
352    /// `margin` negative or not finite, `crossover_rate`/`mutation_rate` or
353    /// `target_utilization` outside `[0, 1]`, a `population_size` set below 2,
354    /// a `max_iterations` set to 0.
355    pub fn validate(&self) -> Result<()> {
356        use crate::error::{check_at_least, check_range};
357        check_at_least("spacing", self.spacing, 0.0)?;
358        check_at_least("margin", self.margin, 0.0)?;
359        if let Some(size) = self.population_size {
360            Self::check_population_size(size as i128)?;
361        }
362        if let Some(iterations) = self.max_iterations {
363            Self::check_max_iterations(i128::from(iterations))?;
364        }
365        check_range("crossover_rate", self.crossover_rate, 0.0, 1.0)?;
366        check_range("mutation_rate", self.mutation_rate, 0.0, 1.0)?;
367        if let Some(util) = self.target_utilization {
368            check_range("target_utilization", util, 0.0, 1.0)?;
369        }
370        Ok(())
371    }
372
373    /// A population size: at least 2, the fewest a generation can cross. A
374    /// request's value and a configuration's own are judged here alike.
375    pub(crate) fn check_population_size(value: i128) -> Result<usize> {
376        crate::error::whole_at_least("population_size", value, 2)
377    }
378
379    /// An iteration budget: at least 1. A request's value and a
380    /// configuration's own are judged here alike.
381    pub(crate) fn check_max_iterations(value: i128) -> Result<u64> {
382        crate::error::whole_at_least("max_iterations", value, 1)
383    }
384
385    /// Sets the optimization strategy.
386    pub fn with_strategy(mut self, strategy: Strategy) -> Self {
387        self.strategy = strategy;
388        self
389    }
390
391    /// Sets the spacing between geometries.
392    pub fn with_spacing(mut self, spacing: f64) -> Self {
393        self.spacing = spacing;
394        self
395    }
396
397    /// Sets the margin from boundary edges.
398    pub fn with_margin(mut self, margin: f64) -> Self {
399        self.margin = margin;
400        self
401    }
402
403    /// Sets the time limit in milliseconds.
404    pub fn with_time_limit(mut self, ms: u64) -> Self {
405        self.time_limit_ms = ms;
406        self
407    }
408
409    /// Sets the target utilization.
410    pub fn with_target_utilization(mut self, util: f64) -> Self {
411        self.target_utilization = Some(util);
412        self
413    }
414
415    /// Sets the RNG seed for reproducible stochastic runs (see [`Config::seed`]).
416    pub fn with_seed(mut self, seed: u64) -> Self {
417        self.seed = Some(seed);
418        self
419    }
420
421    /// Sets the population size of the population-based strategies, in place
422    /// of each strategy's own (see [`Config::population_size`]).
423    pub fn with_population_size(mut self, size: usize) -> Self {
424        self.population_size = Some(size);
425        self
426    }
427
428    /// Sets the generation budget of the population-based strategies, in
429    /// place of each strategy's own (see [`Config::max_generations`]).
430    pub fn with_max_generations(mut self, generations: u32) -> Self {
431        self.max_generations = Some(generations);
432        self
433    }
434
435    /// Sets the iteration budget of the single-solution searches, in place
436    /// of each strategy's own (see [`Config::max_iterations`]).
437    pub fn with_max_iterations(mut self, iterations: u64) -> Self {
438        self.max_iterations = Some(iterations);
439        self
440    }
441}
442
443/// Progress callback for long-running operations.
444pub type ProgressCallback = Box<dyn Fn(ProgressInfo) + Send + Sync>;
445
446/// Progress information during solving.
447#[derive(Debug, Clone, Default)]
448pub struct ProgressInfo {
449    /// Current iteration/generation number.
450    pub iteration: u32,
451    /// Total expected iterations (0 if unknown).
452    pub total_iterations: u32,
453    /// Current best utilization (0.0 to 1.0).
454    pub utilization: f64,
455    /// Current best fitness value.
456    pub best_fitness: f64,
457    /// Number of items placed.
458    pub items_placed: usize,
459    /// Total number of items.
460    pub total_items: usize,
461    /// Elapsed time in milliseconds.
462    pub elapsed_ms: u64,
463    /// Current phase/stage description.
464    pub phase: String,
465    /// Whether the solver is still running.
466    pub running: bool,
467}
468
469impl ProgressInfo {
470    /// Creates a new progress info with default values.
471    pub fn new() -> Self {
472        Self {
473            running: true,
474            ..Default::default()
475        }
476    }
477
478    /// Sets the iteration info.
479    pub fn with_iteration(mut self, current: u32, total: u32) -> Self {
480        self.iteration = current;
481        self.total_iterations = total;
482        self
483    }
484
485    /// Sets the utilization.
486    pub fn with_utilization(mut self, utilization: f64) -> Self {
487        self.utilization = utilization;
488        self
489    }
490
491    /// Sets the best fitness.
492    pub fn with_fitness(mut self, fitness: f64) -> Self {
493        self.best_fitness = fitness;
494        self
495    }
496
497    /// Sets the items placed info.
498    pub fn with_items(mut self, placed: usize, total: usize) -> Self {
499        self.items_placed = placed;
500        self.total_items = total;
501        self
502    }
503
504    /// Sets the elapsed time.
505    pub fn with_elapsed(mut self, elapsed_ms: u64) -> Self {
506        self.elapsed_ms = elapsed_ms;
507        self
508    }
509
510    /// Sets the phase description.
511    pub fn with_phase(mut self, phase: impl Into<String>) -> Self {
512        self.phase = phase.into();
513        self
514    }
515
516    /// One step of a search: step `iteration` of `total` (0 when unknown),
517    /// the best fitness so far, the time since the search began and its
518    /// `phase` -- finished when the search is no longer `running`.
519    pub fn search_step(
520        phase: &str,
521        iteration: u64,
522        total: u64,
523        best_fitness: f64,
524        elapsed_ms: u64,
525        running: bool,
526    ) -> Self {
527        let narrow = |n: u64| u32::try_from(n).unwrap_or(u32::MAX);
528        let info = Self::new()
529            .with_iteration(narrow(iteration), narrow(total))
530            .with_fitness(best_fitness)
531            .with_elapsed(elapsed_ms)
532            .with_phase(phase);
533        if running {
534            info
535        } else {
536            info.finished()
537        }
538    }
539
540    /// Marks the solver as finished.
541    pub fn finished(mut self) -> Self {
542        self.running = false;
543        self
544    }
545
546    /// Calculates the progress percentage (0.0 to 1.0).
547    pub fn progress_percent(&self) -> f64 {
548        if self.total_iterations > 0 {
549            self.iteration as f64 / self.total_iterations as f64
550        } else {
551            0.0
552        }
553    }
554}
555
556/// Trait for nesting/packing solvers.
557pub trait Solver {
558    /// The geometry type this solver handles.
559    type Geometry: Geometry;
560    /// The boundary type this solver handles.
561    type Boundary: Boundary;
562    /// The scalar type for coordinates.
563    type Scalar;
564
565    /// Solves the nesting/packing problem.
566    fn solve(
567        &self,
568        geometries: &[Self::Geometry],
569        boundary: &Self::Boundary,
570    ) -> Result<SolveResult<Self::Scalar>>;
571
572    /// Solves with a progress callback.
573    fn solve_with_progress(
574        &self,
575        geometries: &[Self::Geometry],
576        boundary: &Self::Boundary,
577        callback: ProgressCallback,
578    ) -> Result<SolveResult<Self::Scalar>>;
579
580    /// Cancels an ongoing solve operation.
581    fn cancel(&self);
582}
583
584#[cfg(test)]
585mod config_tests {
586    use super::Config;
587
588    #[test]
589    fn a_target_utilization_above_one_is_refused_not_clamped() {
590        let config = Config::default().with_target_utilization(1.5);
591        assert_eq!(config.target_utilization, Some(1.5), "stored as given");
592        let err = config.validate().expect_err("1.5 is not a utilization");
593        assert!(err.to_string().contains("target_utilization"), "{err}");
594        assert!(Config::default()
595            .with_target_utilization(0.9)
596            .validate()
597            .is_ok());
598        let nan_rate = Config {
599            mutation_rate: f64::NAN,
600            ..Config::default()
601        };
602        assert!(nan_rate.validate().is_err());
603    }
604
605    /// A budget left unset is the strategy's to choose; one that is set is
606    /// stored as given and judged by `validate`.
607    #[test]
608    fn a_population_is_stored_as_given_and_judged_by_validate() {
609        let unset = Config::default();
610        assert_eq!(unset.population_size, None);
611        assert_eq!(unset.max_generations, None);
612        assert!(unset.validate().is_ok());
613
614        let set = Config::default()
615            .with_population_size(100)
616            .with_max_generations(0);
617        assert_eq!(set.population_size, Some(100));
618        assert_eq!(set.max_generations, Some(0), "0 generations is a budget");
619        assert!(set.validate().is_ok());
620
621        let one = Config::default().with_population_size(1);
622        assert_eq!(one.population_size, Some(1), "stored as given");
623        let err = one.validate().expect_err("a population of 1 cannot cross");
624        assert_eq!(err.code(), "parameter_out_of_range");
625        assert_eq!(
626            err.to_string(),
627            "population_size must be a whole number >= 2, got 1"
628        );
629    }
630}