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}