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
37impl Strategy {
38    /// The short name of each strategy -- what a refusal of an unknown name
39    /// lists as expected. [`Strategy::parse`] reads these and the longer
40    /// forms in [`Strategy::SPELLINGS`].
41    /// Whether this strategy reads `setting` -- one of the settings only some
42    /// strategies use. A request that names one its strategy does not read is
43    /// refused rather than run without it (see `ConfigRequest::to_config`).
44    /// Settings every strategy reads are not listed and always read.
45    ///
46    /// - `crossover_rate`, `mutation_rate`: the genetic algorithm.
47    /// - `population_size`, `max_generations`: the two population searches.
48    /// - `seed`: the stochastic searches (and the hybrid's ALNS fallback).
49    /// - `target_utilization`: the searches that stop on a target (GA, BRKGA,
50    ///   SA).
51    pub fn reads_setting(self, setting: &str) -> bool {
52        use Strategy::*;
53        match setting {
54            "crossover_rate" | "mutation_rate" => matches!(self, GeneticAlgorithm),
55            "population_size" | "max_generations" => matches!(self, GeneticAlgorithm | Brkga),
56            "seed" => matches!(
57                self,
58                GeneticAlgorithm | Brkga | SimulatedAnnealing | Gdrr | Alns | HybridExact
59            ),
60            "target_utilization" => matches!(self, GeneticAlgorithm | Brkga | SimulatedAnnealing),
61            _ => true,
62        }
63    }
64
65    /// The names of the strategies that read `setting` (see
66    /// [`Self::reads_setting`]).
67    pub fn readers_of(setting: &str) -> Vec<&'static str> {
68        Self::NAMES
69            .iter()
70            .copied()
71            .filter(|n| Self::parse(n).is_some_and(|s| s.reads_setting(setting)))
72            .collect()
73    }
74
75    pub const NAMES: [&'static str; 10] = [
76        "blf",
77        "nfp",
78        "ga",
79        "brkga",
80        "sa",
81        "ep",
82        "gdrr",
83        "alns",
84        "milpexact",
85        "hybridexact",
86    ];
87
88    /// Every spelling [`Strategy::parse`] reads, with the strategy it names:
89    /// the [`Strategy::NAMES`] and their longer forms.
90    pub const SPELLINGS: [(&'static str, Self); 18] = [
91        ("blf", Self::BottomLeftFill),
92        ("bottomleftfill", Self::BottomLeftFill),
93        ("nfp", Self::NfpGuided),
94        ("nfpguided", Self::NfpGuided),
95        ("ga", Self::GeneticAlgorithm),
96        ("genetic", Self::GeneticAlgorithm),
97        ("geneticalgorithm", Self::GeneticAlgorithm),
98        ("brkga", Self::Brkga),
99        ("sa", Self::SimulatedAnnealing),
100        ("simulatedannealing", Self::SimulatedAnnealing),
101        ("ep", Self::ExtremePoint),
102        ("extremepoint", Self::ExtremePoint),
103        ("gdrr", Self::Gdrr),
104        ("alns", Self::Alns),
105        ("milpexact", Self::MilpExact),
106        ("exact", Self::MilpExact),
107        ("hybridexact", Self::HybridExact),
108        ("hybrid", Self::HybridExact),
109    ];
110
111    /// Parses a strategy name (case-insensitive, whitespace-trimmed).
112    ///
113    /// Returns `None` for unrecognized names so callers surface an explicit
114    /// error instead of silently falling back to a default strategy (which
115    /// hides typos from consumers). This is the single source of truth for
116    /// strategy-name parsing across all bindings (C FFI, Python, WASM).
117    pub fn parse(name: &str) -> Option<Self> {
118        let name = name.trim().to_lowercase();
119        Self::SPELLINGS
120            .iter()
121            .find(|(spelling, _)| *spelling == name)
122            .map(|&(_, strategy)| strategy)
123    }
124}
125
126/// Common configuration for solvers.
127///
128/// It holds no thread count: a build with the `parallel` feature runs its
129/// parallel work on the rayon pool the solve is called from, so
130/// `rayon::ThreadPool::install` around the solve (or `RAYON_NUM_THREADS`)
131/// bounds it; without the feature a solve runs on the calling thread.
132#[derive(Debug, Clone)]
133#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
134pub struct Config {
135    /// Optimization strategy.
136    pub strategy: Strategy,
137
138    /// Minimum distance between any two placed geometries. It does not apply
139    /// between a geometry and the boundary — that is `margin`.
140    pub spacing: f64,
141
142    /// Minimum distance between any placed geometry and the boundary edge.
143    pub margin: f64,
144
145    /// Maximum computation time in milliseconds (0 = unlimited).
146    ///
147    /// The search strategies check it between individual placements, so a solve
148    /// returns within the limit plus the time of the greedy bottom-left pass it is
149    /// compared against. The layout returned is the best one found in that time.
150    pub time_limit_ms: u64,
151
152    /// Target utilization (0.0 - 1.0). Solver stops if reached.
153    pub target_utilization: Option<f64>,
154
155    // GA-specific parameters
156    /// Population size for the population-based strategies (GA, BRKGA).
157    ///
158    /// `None` runs each strategy with the size it is tuned for -- 2D GA and
159    /// BRKGA 30, 3D GA 100, 3D BRKGA 50; `Some(n)` runs it with `n`, at least
160    /// 2. The other strategies have no population and do not read it.
161    pub population_size: Option<usize>,
162
163    /// Generation budget for the population-based strategies (GA, BRKGA).
164    ///
165    /// `None` runs each strategy with the budget it is tuned for -- 2D GA and
166    /// BRKGA 50, 3D GA 500, 3D BRKGA 100; `Some(n)` allows `n` generations (0
167    /// evaluates the first population only). A run also ends on the time
168    /// limit, on cancellation and when the best layout stops improving.
169    pub max_generations: Option<u32>,
170
171    /// Crossover rate for GA (0.0 - 1.0).
172    pub crossover_rate: f64,
173
174    /// Mutation rate for GA (0.0 - 1.0).
175    pub mutation_rate: f64,
176
177    /// Elite count for GA.
178    pub elite_count: usize,
179
180    /// Optional RNG seed for reproducible runs of the stochastic strategies
181    /// (GA, BRKGA, SA, GDRR, ALNS -- 2D and 3D). `None` seeds from system
182    /// entropy (non-deterministic).
183    ///
184    /// A seeded run draws every random choice from the seed, so the same
185    /// request with the same seed returns the same layout **when the run ends
186    /// on its own budget** -- the generation or iteration count, convergence,
187    /// or the target utilization. A run cut short by the clock is not
188    /// reproduced: how far a search gets before `time_limit_ms` (or a
189    /// cancellation) stops it depends on the machine and its load. In 2D a
190    /// search keeps a time budget of its own even when `time_limit_ms` is 0
191    /// (15 s for GA and BRKGA, 10 s for SA, GDRR and ALNS), so a large 2D
192    /// request can reach it.
193    pub seed: Option<u64>,
194}
195
196impl Default for Config {
197    fn default() -> Self {
198        Self {
199            strategy: Strategy::default(),
200            spacing: 0.0,
201            margin: 0.0,
202            time_limit_ms: 30000,
203            target_utilization: None,
204            population_size: None,
205            max_generations: None,
206            crossover_rate: 0.85,
207            mutation_rate: 0.05,
208            elite_count: 5,
209            seed: None,
210        }
211    }
212}
213
214impl Config {
215    /// Creates a new configuration with default values.
216    pub fn new() -> Self {
217        Self::default()
218    }
219
220    /// Checks the fields a caller sets against their ranges -- the builders
221    /// store what they are given, and every solver calls this before it runs.
222    ///
223    /// # Errors
224    /// `ConfigError` naming the first field out of range: `spacing` or
225    /// `margin` negative or not finite, `crossover_rate`/`mutation_rate` or
226    /// `target_utilization` outside `[0, 1]`, a `population_size` set below 2.
227    pub fn validate(&self) -> Result<()> {
228        use crate::error::{check_at_least, check_range};
229        check_at_least("spacing", self.spacing, 0.0)?;
230        check_at_least("margin", self.margin, 0.0)?;
231        if let Some(size) = self.population_size {
232            Self::check_population_size(size as i128)?;
233        }
234        check_range("crossover_rate", self.crossover_rate, 0.0, 1.0)?;
235        check_range("mutation_rate", self.mutation_rate, 0.0, 1.0)?;
236        if let Some(util) = self.target_utilization {
237            check_range("target_utilization", util, 0.0, 1.0)?;
238        }
239        Ok(())
240    }
241
242    /// A population size: at least 2, the fewest a generation can cross. A
243    /// request's value and a configuration's own are judged here alike.
244    pub(crate) fn check_population_size(value: i128) -> Result<usize> {
245        crate::error::whole_at_least("population_size", value, 2)
246    }
247
248    /// Sets the optimization strategy.
249    pub fn with_strategy(mut self, strategy: Strategy) -> Self {
250        self.strategy = strategy;
251        self
252    }
253
254    /// Sets the spacing between geometries.
255    pub fn with_spacing(mut self, spacing: f64) -> Self {
256        self.spacing = spacing;
257        self
258    }
259
260    /// Sets the margin from boundary edges.
261    pub fn with_margin(mut self, margin: f64) -> Self {
262        self.margin = margin;
263        self
264    }
265
266    /// Sets the time limit in milliseconds.
267    pub fn with_time_limit(mut self, ms: u64) -> Self {
268        self.time_limit_ms = ms;
269        self
270    }
271
272    /// Sets the target utilization.
273    pub fn with_target_utilization(mut self, util: f64) -> Self {
274        self.target_utilization = Some(util);
275        self
276    }
277
278    /// Sets the RNG seed for reproducible stochastic runs (see [`Config::seed`]).
279    pub fn with_seed(mut self, seed: u64) -> Self {
280        self.seed = Some(seed);
281        self
282    }
283
284    /// Sets the population size of the population-based strategies, in place
285    /// of each strategy's own (see [`Config::population_size`]).
286    pub fn with_population_size(mut self, size: usize) -> Self {
287        self.population_size = Some(size);
288        self
289    }
290
291    /// Sets the generation budget of the population-based strategies, in
292    /// place of each strategy's own (see [`Config::max_generations`]).
293    pub fn with_max_generations(mut self, generations: u32) -> Self {
294        self.max_generations = Some(generations);
295        self
296    }
297}
298
299/// Progress callback for long-running operations.
300pub type ProgressCallback = Box<dyn Fn(ProgressInfo) + Send + Sync>;
301
302/// Progress information during solving.
303#[derive(Debug, Clone, Default)]
304pub struct ProgressInfo {
305    /// Current iteration/generation number.
306    pub iteration: u32,
307    /// Total expected iterations (0 if unknown).
308    pub total_iterations: u32,
309    /// Current best utilization (0.0 to 1.0).
310    pub utilization: f64,
311    /// Current best fitness value.
312    pub best_fitness: f64,
313    /// Number of items placed.
314    pub items_placed: usize,
315    /// Total number of items.
316    pub total_items: usize,
317    /// Elapsed time in milliseconds.
318    pub elapsed_ms: u64,
319    /// Current phase/stage description.
320    pub phase: String,
321    /// Whether the solver is still running.
322    pub running: bool,
323}
324
325impl ProgressInfo {
326    /// Creates a new progress info with default values.
327    pub fn new() -> Self {
328        Self {
329            running: true,
330            ..Default::default()
331        }
332    }
333
334    /// Sets the iteration info.
335    pub fn with_iteration(mut self, current: u32, total: u32) -> Self {
336        self.iteration = current;
337        self.total_iterations = total;
338        self
339    }
340
341    /// Sets the utilization.
342    pub fn with_utilization(mut self, utilization: f64) -> Self {
343        self.utilization = utilization;
344        self
345    }
346
347    /// Sets the best fitness.
348    pub fn with_fitness(mut self, fitness: f64) -> Self {
349        self.best_fitness = fitness;
350        self
351    }
352
353    /// Sets the items placed info.
354    pub fn with_items(mut self, placed: usize, total: usize) -> Self {
355        self.items_placed = placed;
356        self.total_items = total;
357        self
358    }
359
360    /// Sets the elapsed time.
361    pub fn with_elapsed(mut self, elapsed_ms: u64) -> Self {
362        self.elapsed_ms = elapsed_ms;
363        self
364    }
365
366    /// Sets the phase description.
367    pub fn with_phase(mut self, phase: impl Into<String>) -> Self {
368        self.phase = phase.into();
369        self
370    }
371
372    /// Marks the solver as finished.
373    pub fn finished(mut self) -> Self {
374        self.running = false;
375        self
376    }
377
378    /// Calculates the progress percentage (0.0 to 1.0).
379    pub fn progress_percent(&self) -> f64 {
380        if self.total_iterations > 0 {
381            self.iteration as f64 / self.total_iterations as f64
382        } else {
383            0.0
384        }
385    }
386}
387
388/// Trait for nesting/packing solvers.
389pub trait Solver {
390    /// The geometry type this solver handles.
391    type Geometry: Geometry;
392    /// The boundary type this solver handles.
393    type Boundary: Boundary;
394    /// The scalar type for coordinates.
395    type Scalar;
396
397    /// Solves the nesting/packing problem.
398    fn solve(
399        &self,
400        geometries: &[Self::Geometry],
401        boundary: &Self::Boundary,
402    ) -> Result<SolveResult<Self::Scalar>>;
403
404    /// Solves with a progress callback.
405    fn solve_with_progress(
406        &self,
407        geometries: &[Self::Geometry],
408        boundary: &Self::Boundary,
409        callback: ProgressCallback,
410    ) -> Result<SolveResult<Self::Scalar>>;
411
412    /// Cancels an ongoing solve operation.
413    fn cancel(&self);
414}
415
416#[cfg(test)]
417mod config_tests {
418    use super::Config;
419
420    #[test]
421    fn a_target_utilization_above_one_is_refused_not_clamped() {
422        let config = Config::default().with_target_utilization(1.5);
423        assert_eq!(config.target_utilization, Some(1.5), "stored as given");
424        let err = config.validate().expect_err("1.5 is not a utilization");
425        assert!(err.to_string().contains("target_utilization"), "{err}");
426        assert!(Config::default()
427            .with_target_utilization(0.9)
428            .validate()
429            .is_ok());
430        let nan_rate = Config {
431            mutation_rate: f64::NAN,
432            ..Config::default()
433        };
434        assert!(nan_rate.validate().is_err());
435    }
436
437    /// A budget left unset is the strategy's to choose; one that is set is
438    /// stored as given and judged by `validate`.
439    #[test]
440    fn a_population_is_stored_as_given_and_judged_by_validate() {
441        let unset = Config::default();
442        assert_eq!(unset.population_size, None);
443        assert_eq!(unset.max_generations, None);
444        assert!(unset.validate().is_ok());
445
446        let set = Config::default()
447            .with_population_size(100)
448            .with_max_generations(0);
449        assert_eq!(set.population_size, Some(100));
450        assert_eq!(set.max_generations, Some(0), "0 generations is a budget");
451        assert!(set.validate().is_ok());
452
453        let one = Config::default().with_population_size(1);
454        assert_eq!(one.population_size, Some(1), "stored as given");
455        let err = one.validate().expect_err("a population of 1 cannot cross");
456        assert_eq!(err.code(), "parameter_out_of_range");
457        assert_eq!(
458            err.to_string(),
459            "population_size must be a whole number >= 2, got 1"
460        );
461    }
462}