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}