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