1use crate::alns_nesting::run_alns_nesting;
4use crate::boundary::Boundary2D;
5use crate::brkga_nesting::run_brkga_nesting;
6use crate::clamp_placement_to_boundary_with_margin;
7use crate::ga_nesting::run_ga_nesting;
8use crate::gdrr_nesting::run_gdrr_nesting;
9use crate::geometry::Geometry2D;
10use crate::nfp::{
11 compute_ifp_with_margin_and_mirror, compute_nfp_mirrored, find_bottom_left_placement,
12 rotate_nfp, translate_nfp, Nfp, NfpCache, PackingAxis, PlacedGeometry,
13};
14#[cfg(feature = "milp")]
15use crate::nfp_cm_solver::run_nfp_cm_nesting;
16use crate::sa_nesting::run_sa_nesting;
17use crate::validate_and_filter_placements;
18use u_nesting_core::alns::AlnsConfig;
19use u_nesting_core::brkga::BrkgaConfig;
20#[cfg(feature = "milp")]
21use u_nesting_core::exact::ExactConfig;
22use u_nesting_core::ga::GaConfig;
23use u_nesting_core::gdrr::GdrrConfig;
24use u_nesting_core::geometry::{Boundary, Geometry};
25use u_nesting_core::sa::SaConfig;
26use u_nesting_core::solver::{
27 offered_names, Config, Dimension, ProgressCallback, ProgressInfo, Solver, Strategy,
28};
29use u_nesting_core::{Error, Placement, Result, SolveResult, StopReason};
30
31use crate::placement_utils::{hole_nfps, inset_boundary, nesting_fitness, offset_nfp};
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::sync::Arc;
34use u_nesting_core::timing::Timer;
35
36const SEARCH_POPULATION: usize = 30;
41
42const SEARCH_GENERATIONS: u32 = 50;
45
46fn mirror_candidates(geom: &Geometry2D) -> &'static [bool] {
58 if geom.allow_flip() {
59 &[false, true]
60 } else {
61 &[false]
62 }
63}
64
65fn solution_quality(
79 result: &SolveResult<f64>,
80 geometries: &[Geometry2D],
81 boundary: &Boundary2D,
82) -> (usize, f64) {
83 use std::collections::HashMap;
84 let geom_map: HashMap<_, _> = geometries.iter().map(|g| (g.id().clone(), g)).collect();
85
86 let mut min_x = f64::INFINITY;
87 let mut min_y = f64::INFINITY;
88 let mut max_x = f64::NEG_INFINITY;
89 let mut max_y = f64::NEG_INFINITY;
90
91 for p in &result.placements {
92 if let Some(geom) = geom_map.get(&p.geometry_id) {
93 let x = p.position.first().copied().unwrap_or(0.0);
94 let y = p.position.get(1).copied().unwrap_or(0.0);
95 let rot = p.rotation.first().copied().unwrap_or(0.0);
96 let (g_min, g_max) = geom.aabb_at_rotation(rot);
97 min_x = min_x.min(x + g_min[0]);
98 min_y = min_y.min(y + g_min[1]);
99 max_x = max_x.max(x + g_max[0]);
100 max_y = max_y.max(y + g_max[1]);
101 }
102 }
103
104 if result.placements.is_empty() {
105 return (0, f64::INFINITY);
106 }
107
108 let strip_length = match PackingAxis::of(boundary) {
110 PackingAxis::Y => max_y - min_y,
111 PackingAxis::X => max_x - min_x,
112 };
113 (result.placements.len(), strip_length)
114}
115
116pub struct Nester2D {
118 config: Config,
119 cancelled: Arc<AtomicBool>,
120 nfp_cache: NfpCache,
121}
122
123#[cfg(feature = "milp")]
134fn milp_grid_step(geometries: &[Geometry2D], boundary: &Boundary2D) -> f64 {
135 let smallest_side = geometries
136 .iter()
137 .map(|g| {
138 let (g_min, g_max) = g.aabb_at_rotation(0.0);
139 (g_max[0] - g_min[0]).min(g_max[1] - g_min[1])
140 })
141 .fold(f64::INFINITY, f64::min);
142 if !smallest_side.is_finite() || smallest_side <= 0.0 {
143 return 1.0;
144 }
145 let (b_min, b_max) = boundary.aabb();
146 let longest_side = (b_max[0] - b_min[0]).max(b_max[1] - b_min[1]);
147 (smallest_side / 4.0).max(1.0).max(longest_side / 256.0)
150}
151
152#[cfg(feature = "milp")]
158fn milp_time_limit(requested_ms: u64) -> u64 {
159 if requested_ms == 0 {
160 u64::MAX / 2
161 } else {
162 requested_ms
163 }
164}
165
166#[derive(Debug, Clone, Copy)]
169struct BlfCandidate {
170 rotation: f64,
171 extent_across: f64,
172 extent_along: f64,
173 place_across: f64,
174 place_along: f64,
175 opened_row: bool,
177}
178
179fn stop_phase(stop: StopReason) -> &'static str {
181 match stop {
182 StopReason::Cancelled => "Cancelled",
183 _ => "Time Limit Reached",
184 }
185}
186
187impl Nester2D {
188 fn run_strategy_reporting(
194 &self,
195 geometries: &[Geometry2D],
196 boundary: &Boundary2D,
197 progress: Option<&ProgressCallback>,
198 ) -> Result<SolveResult<f64>> {
199 match self.config.strategy {
200 Strategy::BottomLeftFill => self.bottom_left_fill_impl(geometries, boundary, progress),
201 Strategy::NfpGuided => self.nfp_guided_blf_impl(geometries, boundary, progress),
202 Strategy::GeneticAlgorithm => self.genetic_algorithm(geometries, boundary, progress),
203 Strategy::Brkga => self.brkga(geometries, boundary, progress),
204 Strategy::SimulatedAnnealing => {
205 self.simulated_annealing(geometries, boundary, progress)
206 }
207 Strategy::Gdrr => self.gdrr(geometries, boundary, progress),
208 Strategy::Alns => self.alns(geometries, boundary, progress),
209 #[cfg(feature = "milp")]
210 Strategy::MilpExact => self.milp_exact(geometries, boundary),
211 #[cfg(feature = "milp")]
212 Strategy::HybridExact => self.hybrid_exact(geometries, boundary),
213 Strategy::ExtremePoint => Err(self.unoffered_strategy()),
215 #[cfg(not(feature = "milp"))]
216 Strategy::MilpExact | Strategy::HybridExact => Err(self.unoffered_strategy()),
217 }
218 }
219
220 fn stop_requested(&self, start: &Timer) -> Option<StopReason> {
223 if self.cancelled.load(Ordering::Relaxed) {
224 Some(StopReason::Cancelled)
225 } else if self.config.time_limit_ms > 0 && start.elapsed_ms() >= self.config.time_limit_ms {
226 Some(StopReason::TimeLimit)
227 } else {
228 None
229 }
230 }
231
232 pub fn new(config: Config) -> Self {
234 Self {
235 config,
236 cancelled: Arc::new(AtomicBool::new(false)),
237 nfp_cache: NfpCache::new(),
238 }
239 }
240
241 fn with_time_limit(&self, time_limit_ms: u64) -> Self {
244 Self {
245 config: Config {
246 time_limit_ms,
247 ..self.config.clone()
248 },
249 cancelled: self.cancelled.clone(),
250 nfp_cache: NfpCache::new(),
251 }
252 }
253
254 fn constructive_fill(&self) -> Self {
258 Self {
259 config: Config {
260 strategy: Strategy::BottomLeftFill,
261 time_limit_ms: 0,
262 ..self.config.clone()
263 },
264 cancelled: self.cancelled.clone(),
265 nfp_cache: NfpCache::new(),
266 }
267 }
268
269 pub fn cancel_handle(&self) -> Arc<AtomicBool> {
272 self.cancelled.clone()
273 }
274
275 pub fn default_config() -> Self {
277 Self::new(Config::default())
278 }
279
280 fn iteration_budget<T: TryFrom<u64> + Copy>(&self, tuned: T) -> T {
283 self.config
284 .max_iterations
285 .map_or(tuned, |n| T::try_from(n).unwrap_or(tuned))
286 }
287
288 fn ga_config(&self) -> GaConfig {
292 let config = GaConfig::default()
293 .with_population_size(self.config.population_size.unwrap_or(SEARCH_POPULATION))
294 .with_max_generations(self.config.max_generations.unwrap_or(SEARCH_GENERATIONS))
295 .with_crossover_rate(self.config.crossover_rate)
296 .with_mutation_rate(self.config.mutation_rate)
297 .with_elite_count(self.config.elite_count);
298 match self.target_fitness() {
299 Some(target) => config.with_target_fitness(target),
300 None => config,
301 }
302 }
303
304 fn brkga_config(&self) -> BrkgaConfig {
307 let config = BrkgaConfig::default()
308 .with_population_size(self.config.population_size.unwrap_or(SEARCH_POPULATION))
309 .with_max_generations(self.config.max_generations.unwrap_or(SEARCH_GENERATIONS))
310 .with_elite_fraction(0.2)
311 .with_mutant_fraction(0.15)
312 .with_elite_bias(0.7);
313 match self.target_fitness() {
314 Some(target) => config.with_target_fitness(target),
315 None => config,
316 }
317 }
318
319 fn gdrr_config(&self, time_limit_ms: u64) -> GdrrConfig {
321 GdrrConfig {
322 seed: self.config.seed,
323 ..GdrrConfig::default()
324 .with_max_iterations(self.iteration_budget(1000))
325 .with_time_limit_ms(time_limit_ms)
326 .with_ruin_ratio(0.1, 0.3) .with_lahc_list_length(30) }
329 }
330
331 fn alns_config(&self, time_limit_ms: u64) -> AlnsConfig {
333 AlnsConfig {
334 seed: self.config.seed,
335 ..AlnsConfig::default()
336 .with_max_iterations(self.iteration_budget(1000))
337 .with_time_limit_ms(time_limit_ms)
338 .with_segment_size(50) .with_scores(33.0, 9.0, 13.0)
340 .with_reaction_factor(0.15) .with_temperature(100.0, 0.999, 0.1) }
343 }
344
345 fn bottom_left_fill(
355 &self,
356 geometries: &[Geometry2D],
357 boundary: &Boundary2D,
358 ) -> Result<SolveResult<f64>> {
359 self.bottom_left_fill_impl(geometries, boundary, None)
360 }
361
362 fn nfp_guided_blf_impl(
365 &self,
366 geometries: &[Geometry2D],
367 boundary: &Boundary2D,
368 callback: Option<&ProgressCallback>,
369 ) -> Result<SolveResult<f64>> {
370 let start = Timer::now();
371 let mut result = SolveResult::new();
372 let mut placements = Vec::new();
373 let mut placed_geometries: Vec<PlacedGeometry> = Vec::new();
374 let report = |info: ProgressInfo| {
375 if let Some(callback) = callback {
376 callback(info);
377 }
378 };
379 let total_pieces: usize = geometries.iter().map(|g| g.quantity()).sum();
380 let mut placed_count = 0usize;
381 report(
382 ProgressInfo::new()
383 .with_phase("NFP Placement")
384 .with_items(0, total_pieces)
385 .with_elapsed(0),
386 );
387
388 let margin = self.config.margin;
389 let spacing = self.config.spacing;
390
391 let boundary_polygon = inset_boundary(boundary, margin);
393
394 let mut total_placed_area = 0.0;
395
396 let sample_step = self.compute_sample_step(geometries);
398
399 for geom in geometries {
400 geom.validate()?;
401
402 let rotation_angles: Vec<f64> = geom.rotations();
405
406 let mirror_candidates = mirror_candidates(geom);
407
408 for instance in 0..geom.quantity() {
409 if let Some(stop) = self.stop_requested(&start) {
411 result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
412 result.utilization = total_placed_area / boundary.measure();
413 result.computation_time_ms = start.elapsed_ms();
414 result.placements = placements;
415 result.stop_reason = stop;
416 report(
417 ProgressInfo::new()
418 .with_phase(stop_phase(stop))
419 .with_items(placed_count, total_pieces)
420 .with_elapsed(result.computation_time_ms)
421 .finished(),
422 );
423 return Ok(result);
424 }
425
426 let mut best_placement: Option<(f64, f64, f64, bool)> = None; for &rotation in &rotation_angles {
430 for &mirror in mirror_candidates {
431 let ifp = match compute_ifp_with_margin_and_mirror(
433 &boundary_polygon,
434 geom,
435 rotation,
436 0.0,
438 mirror,
439 ) {
440 Ok(ifp) => ifp,
441 Err(_) => continue,
442 };
443
444 if ifp.is_empty() {
445 continue;
446 }
447
448 let mut nfps: Vec<Nfp> = Vec::new();
450 for placed in &placed_geometries {
451 let cache_key = (
454 placed.geometry.id().as_str(),
455 geom.id().as_str(),
456 rotation - placed.rotation, placed.mirrored,
458 mirror,
459 );
460
461 let nfp_at_origin =
466 match self.nfp_cache.get_or_compute_mirrored(cache_key, || {
467 let placed_at_origin = placed.geometry.clone();
468 compute_nfp_mirrored(
469 &placed_at_origin,
470 geom,
471 rotation - placed.rotation,
472 placed.mirrored,
473 mirror,
474 )
475 .map(|nfp| offset_nfp(&nfp, spacing))
479 }) {
480 Ok(nfp) => nfp,
481 Err(_) => continue,
482 };
483
484 let rotated_nfp = rotate_nfp(&nfp_at_origin, placed.rotation);
487 let translated_nfp = translate_nfp(&rotated_nfp, placed.position);
488 nfps.push(translated_nfp);
489 }
490
491 nfps.extend(hole_nfps(boundary, geom, rotation, mirror, margin));
496 let nfp_refs: Vec<&Nfp> = nfps.iter().collect();
497 if let Some((x, y)) = find_bottom_left_placement(
498 &ifp,
499 &nfp_refs,
500 sample_step,
501 PackingAxis::of(boundary),
502 ) {
503 let is_better =
505 match best_placement {
506 None => true,
507 Some((best_x, best_y, _, _)) => PackingAxis::of(boundary)
508 .precedes((x, y), (best_x, best_y), 1e-6),
509 };
510 if is_better {
511 best_placement = Some((x, y, rotation, mirror));
512 }
513 }
514 }
515 }
516
517 if let Some((x, y, rotation, mirror)) = best_placement {
519 let geom_aabb = geom.aabb_at_rotation_mirrored(rotation, mirror);
526 let boundary_aabb = boundary.aabb();
527
528 if let Some((clamped_x, clamped_y)) = clamp_placement_to_boundary_with_margin(
529 x,
530 y,
531 geom_aabb,
532 boundary_aabb,
533 margin,
534 ) {
535 let placement = Placement::new_2d(
536 geom.id().clone(),
537 instance,
538 clamped_x,
539 clamped_y,
540 rotation,
541 )
542 .with_mirrored(mirror);
543
544 placements.push(placement);
545 placed_geometries.push(
546 PlacedGeometry::new(geom.clone(), (clamped_x, clamped_y), rotation)
547 .with_mirrored(mirror),
548 );
549 total_placed_area += geom.measure();
550 placed_count += 1;
551 report(
552 ProgressInfo::new()
553 .with_phase("NFP Placement")
554 .with_items(placed_count, total_pieces)
555 .with_utilization(total_placed_area / boundary.measure())
556 .with_elapsed(start.elapsed_ms()),
557 );
558 } else {
559 result.unplaced.push(geom.id().clone());
561 }
562 } else {
563 result.unplaced.push(geom.id().clone());
565 }
566 }
567 }
568
569 result.placements = placements;
570 result.boundaries_used = 1;
571 result.utilization = total_placed_area / boundary.measure();
572 result.computation_time_ms = start.elapsed_ms();
573 report(
574 ProgressInfo::new()
575 .with_phase("Complete")
576 .with_items(placed_count, total_pieces)
577 .with_utilization(result.utilization)
578 .with_elapsed(result.computation_time_ms)
579 .finished(),
580 );
581
582 Ok(result)
583 }
584
585 fn compute_sample_step(&self, geometries: &[Geometry2D]) -> f64 {
587 if geometries.is_empty() {
588 return 1.0;
589 }
590
591 let mut min_dim = f64::INFINITY;
593 for geom in geometries {
594 let (g_min, g_max) = geom.aabb();
595 let width = g_max[0] - g_min[0];
596 let height = g_max[1] - g_min[1];
597 min_dim = min_dim.min(width).min(height);
598 }
599
600 (min_dim / 4.0).clamp(0.5, 10.0)
602 }
603
604 fn not_worse_than_baselines(
614 &self,
615 meta: SolveResult<f64>,
616 geometries: &[Geometry2D],
617 boundary: &Boundary2D,
618 greedy: Option<SolveResult<f64>>,
619 ) -> SolveResult<f64> {
620 let baselines = self
621 .bottom_left_fill(geometries, boundary)
622 .ok()
623 .into_iter()
624 .chain(greedy);
625 let quality = |r: &SolveResult<f64>| solution_quality(r, geometries, boundary);
626 meta.floored_at(baselines, |a, b| {
629 let (a, b) = (quality(a), quality(b));
630 a.0 > b.0 || (a.0 == b.0 && a.1 < b.1 - 1e-6)
631 })
632 }
633
634 fn target_fitness(&self) -> Option<f64> {
637 self.config
638 .target_utilization
639 .map(|target| nesting_fitness(1, 1, target))
640 }
641
642 fn search_budget_ms(&self) -> u64 {
652 match self.config.time_limit_ms {
653 0 => u64::MAX / 2,
654 limit => limit,
655 }
656 }
657
658 fn greedy_start(
665 &self,
666 geometries: &[Geometry2D],
667 boundary: &Boundary2D,
668 budget_ms: u64,
669 progress: Option<&ProgressCallback>,
670 ) -> (Option<SolveResult<f64>>, u64) {
671 let started = Timer::now();
672 let greedy = self
673 .with_time_limit(budget_ms)
674 .nfp_guided_blf_impl(geometries, boundary, progress)
675 .ok();
676 (
677 greedy,
678 budget_ms.saturating_sub(started.elapsed_ms()).max(1),
679 )
680 }
681
682 fn genetic_algorithm(
689 &self,
690 geometries: &[Geometry2D],
691 boundary: &Boundary2D,
692 progress: Option<&ProgressCallback>,
693 ) -> Result<SolveResult<f64>> {
694 let (time_limit_ms, greedy) = {
695 let budget = self.search_budget_ms();
696 let (greedy, remaining) = self.greedy_start(geometries, boundary, budget, progress);
697 (remaining, greedy)
698 };
699
700 let ga_config = self
701 .ga_config()
702 .with_time_limit(std::time::Duration::from_millis(time_limit_ms));
703
704 let result = run_ga_nesting(
705 geometries,
706 boundary,
707 &self.config,
708 ga_config,
709 self.cancelled.clone(),
710 greedy.as_ref().map(|g| g.placements.as_slice()),
711 progress,
712 )?;
713
714 Ok(self.not_worse_than_baselines(result, geometries, boundary, greedy))
715 }
716
717 fn brkga(
721 &self,
722 geometries: &[Geometry2D],
723 boundary: &Boundary2D,
724 progress: Option<&ProgressCallback>,
725 ) -> Result<SolveResult<f64>> {
726 let (time_limit_ms, greedy) = {
727 let budget = self.search_budget_ms();
728 let (greedy, remaining) = self.greedy_start(geometries, boundary, budget, progress);
729 (remaining, greedy)
730 };
731
732 let brkga_config = self
733 .brkga_config()
734 .with_time_limit(std::time::Duration::from_millis(time_limit_ms));
735
736 let result = run_brkga_nesting(
737 geometries,
738 boundary,
739 &self.config,
740 brkga_config,
741 self.cancelled.clone(),
742 greedy.as_ref().map(|g| g.placements.as_slice()),
743 progress,
744 )?;
745
746 Ok(self.not_worse_than_baselines(result, geometries, boundary, greedy))
747 }
748
749 fn simulated_annealing(
754 &self,
755 geometries: &[Geometry2D],
756 boundary: &Boundary2D,
757 progress: Option<&ProgressCallback>,
758 ) -> Result<SolveResult<f64>> {
759 let (time_limit_ms, greedy) = {
760 let budget = self.search_budget_ms();
761 let (greedy, remaining) = self.greedy_start(geometries, boundary, budget, progress);
762 (remaining, greedy)
763 };
764
765 let sa_config = SaConfig::default()
766 .with_initial_temp(50.0) .with_final_temp(1.0) .with_cooling_rate(0.9) .with_iterations_per_temp(20) .with_max_iterations(self.iteration_budget(500))
771 .with_time_limit(std::time::Duration::from_millis(time_limit_ms));
772 let sa_config = match self.target_fitness() {
773 Some(target) => sa_config.with_target_fitness(target),
774 None => sa_config,
775 };
776
777 let result = run_sa_nesting(
778 geometries,
779 boundary,
780 &self.config,
781 sa_config,
782 self.cancelled.clone(),
783 greedy.as_ref().map(|g| g.placements.as_slice()),
784 progress,
785 )?;
786
787 Ok(self.not_worse_than_baselines(result, geometries, boundary, greedy))
788 }
789
790 fn gdrr(
792 &self,
793 geometries: &[Geometry2D],
794 boundary: &Boundary2D,
795 progress: Option<&ProgressCallback>,
796 ) -> Result<SolveResult<f64>> {
797 let (time_limit, greedy) = {
798 let budget = self.search_budget_ms();
799 let (greedy, remaining) = self.greedy_start(geometries, boundary, budget, progress);
800 (remaining, greedy)
801 };
802 let result = run_gdrr_nesting(
803 geometries,
804 boundary,
805 &self.config,
806 &self.gdrr_config(time_limit),
807 self.cancelled.clone(),
808 progress,
809 )?;
810
811 Ok(self.not_worse_than_baselines(result, geometries, boundary, greedy))
812 }
813
814 fn alns(
816 &self,
817 geometries: &[Geometry2D],
818 boundary: &Boundary2D,
819 progress: Option<&ProgressCallback>,
820 ) -> Result<SolveResult<f64>> {
821 self.alns_within(geometries, boundary, self.search_budget_ms(), progress)
822 }
823
824 fn alns_within(
827 &self,
828 geometries: &[Geometry2D],
829 boundary: &Boundary2D,
830 budget_ms: u64,
831 progress: Option<&ProgressCallback>,
832 ) -> Result<SolveResult<f64>> {
833 let (greedy, time_limit) = self.greedy_start(geometries, boundary, budget_ms, progress);
834 let result = run_alns_nesting(
835 geometries,
836 boundary,
837 &self.config,
838 &self.alns_config(time_limit),
839 self.cancelled.clone(),
840 progress,
841 )?;
842
843 Ok(self.not_worse_than_baselines(result, geometries, boundary, greedy))
844 }
845
846 #[cfg(feature = "milp")]
859 fn milp_exact(
860 &self,
861 geometries: &[Geometry2D],
862 boundary: &Boundary2D,
863 ) -> Result<SolveResult<f64>> {
864 let exact_config = ExactConfig::default()
869 .with_time_limit_ms(milp_time_limit(self.config.time_limit_ms))
870 .with_max_items(15)
871 .with_grid_step(milp_grid_step(geometries, boundary));
872
873 run_nfp_cm_nesting(
874 geometries,
875 boundary,
876 &self.config,
877 &exact_config,
878 self.cancelled.clone(),
879 )
880 }
881
882 #[cfg(feature = "milp")]
884 fn hybrid_exact(
885 &self,
886 geometries: &[Geometry2D],
887 boundary: &Boundary2D,
888 ) -> Result<SolveResult<f64>> {
889 let started = Timer::now();
890 let total_instances: usize = geometries.iter().map(|g| g.quantity()).sum();
892
893 if total_instances <= 15 {
895 let exact_config = ExactConfig::default()
899 .with_time_limit_ms(milp_time_limit(self.config.time_limit_ms / 2))
900 .with_max_items(15)
901 .with_grid_step(milp_grid_step(geometries, boundary));
902
903 let exact_result = run_nfp_cm_nesting(
904 geometries,
905 boundary,
906 &self.config,
907 &exact_config,
908 self.cancelled.clone(),
909 )?;
910
911 if !exact_result.placements.is_empty() && exact_result.unplaced.is_empty() {
918 return Ok(exact_result);
919 }
920 let heuristic =
921 self.alns_within(geometries, boundary, self.fallback_budget(started), None)?;
922 let (exact_placed, exact_length) =
923 solution_quality(&exact_result, geometries, boundary);
924 let (placed, length) = solution_quality(&heuristic, geometries, boundary);
925 let exact_wins =
926 exact_placed > placed || (exact_placed == placed && exact_length < length);
927 return Ok(if exact_wins { exact_result } else { heuristic });
928 }
929
930 self.alns_within(geometries, boundary, self.fallback_budget(started), None)
935 }
936
937 #[cfg(feature = "milp")]
940 fn fallback_budget(&self, started: Timer) -> u64 {
941 match self.config.time_limit_ms {
942 0 => self.search_budget_ms(),
943 limit => limit.saturating_sub(started.elapsed_ms()).max(1),
944 }
945 }
946
947 fn bottom_left_fill_impl(
955 &self,
956 geometries: &[Geometry2D],
957 boundary: &Boundary2D,
958 callback: Option<&ProgressCallback>,
959 ) -> Result<SolveResult<f64>> {
960 let start = Timer::now();
961 let mut result = SolveResult::new();
962 let mut placements = Vec::new();
963 let report = |info: ProgressInfo| {
964 if let Some(callback) = callback {
965 callback(info);
966 }
967 };
968
969 let axis = PackingAxis::of(boundary);
970 let to_ca = |x: f64, y: f64| match axis {
972 PackingAxis::Y => (x, y),
973 PackingAxis::X => (y, x),
974 };
975 let from_ca = |across: f64, along: f64| match axis {
976 PackingAxis::Y => (across, along),
977 PackingAxis::X => (along, across),
978 };
979
980 let (b_min, b_max) = boundary.aabb();
981 let margin = self.config.margin;
982 let spacing = self.config.spacing;
983 let (min_across, min_along) = to_ca(b_min[0] + margin, b_min[1] + margin);
984 let (max_across, max_along) = to_ca(b_max[0] - margin, b_max[1] - margin);
985
986 let hole_boxes: Vec<(f64, f64, f64, f64)> = boundary
993 .holes()
994 .iter()
995 .filter(|hole| !hole.is_empty())
996 .map(|hole| {
997 let (mut x0, mut y0) = (f64::INFINITY, f64::INFINITY);
998 let (mut x1, mut y1) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
999 for &(x, y) in hole {
1000 x0 = x0.min(x);
1001 y0 = y0.min(y);
1002 x1 = x1.max(x);
1003 y1 = y1.max(y);
1004 }
1005 let (a0, l0) = to_ca(x0 - margin, y0 - margin);
1006 let (a1, l1) = to_ca(x1 + margin, y1 + margin);
1007 (a0.min(a1), l0.min(l1), a0.max(a1), l0.max(l1))
1008 })
1009 .collect();
1010
1011 let blocking_hole_edge = |across: f64, along: f64, ea: f64, el: f64| -> Option<f64> {
1015 hole_boxes
1016 .iter()
1017 .filter(|(a0, l0, a1, l1)| {
1018 across < *a1 && across + ea > *a0 && along < *l1 && along + el > *l0
1019 })
1020 .map(|(_, _, a1, _)| *a1)
1021 .fold(None, |far: Option<f64>, a1| {
1022 Some(far.map_or(a1, |f| f.max(a1)))
1023 })
1024 };
1025
1026 let mut cursor_across = min_across;
1027 let mut cursor_along = min_along;
1028 let mut row_depth = 0.0_f64;
1029 let mut total_placed_area = 0.0;
1030 let total_pieces: usize = geometries.iter().map(|g| g.quantity()).sum();
1031 let mut placed_count = 0usize;
1032
1033 report(
1034 ProgressInfo::new()
1035 .with_phase("BLF Placement")
1036 .with_items(0, total_pieces)
1037 .with_elapsed(0),
1038 );
1039
1040 for geom in geometries {
1041 geom.validate()?;
1042
1043 let rotation_angles: Vec<f64> = geom.rotations();
1045
1046 for instance in 0..geom.quantity() {
1047 if let Some(stop) = self.stop_requested(&start) {
1049 result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
1050 result.utilization = total_placed_area / boundary.measure();
1051 result.computation_time_ms = start.elapsed_ms();
1052 result.placements = placements;
1053 result.stop_reason = stop;
1054 report(
1055 ProgressInfo::new()
1056 .with_phase(stop_phase(stop))
1057 .with_items(placed_count, total_pieces)
1058 .with_elapsed(result.computation_time_ms)
1059 .finished(),
1060 );
1061 return Ok(result);
1062 }
1063
1064 let mut best_fit: Option<BlfCandidate> = None;
1067
1068 for &rotation in &rotation_angles {
1069 let (g_min, g_max) = geom.aabb_at_rotation(rotation);
1070 let (extent_across, extent_along) =
1071 to_ca(g_max[0] - g_min[0], g_max[1] - g_min[1]);
1072
1073 if extent_across > max_across - min_across
1074 || extent_along > max_along - min_along
1075 {
1076 continue;
1077 }
1078
1079 let mut place_across = cursor_across;
1080 let mut place_along = cursor_along;
1081 let mut depth = row_depth;
1084 let mut opened_row = false;
1085 let mut turns = 0usize;
1091 let max_turns = 4 * (hole_boxes.len() + 1) * (hole_boxes.len() + 2) + 16;
1092 let fits = loop {
1093 if place_across + extent_across > max_across {
1094 place_across = min_across;
1095 place_along += depth + spacing;
1096 depth = extent_along;
1097 opened_row = true;
1098 }
1099 if place_along + extent_along > max_along {
1100 break false;
1101 }
1102 match blocking_hole_edge(
1103 place_across,
1104 place_along,
1105 extent_across,
1106 extent_along,
1107 ) {
1108 None => break true,
1109 Some(past) => place_across = past + spacing,
1110 }
1111 turns += 1;
1112 if turns > max_turns {
1113 break false;
1114 }
1115 };
1116 if !fits {
1117 continue;
1118 }
1119
1120 let cost = |c: &BlfCandidate| {
1125 if c.opened_row {
1126 c.place_along - min_along + c.extent_along
1127 } else {
1128 c.place_across - min_across + c.extent_across
1129 }
1130 };
1131 let candidate = BlfCandidate {
1132 rotation,
1133 extent_across,
1134 extent_along,
1135 place_across,
1136 place_along,
1137 opened_row,
1138 };
1139 let better = best_fit
1140 .as_ref()
1141 .is_none_or(|best| cost(&candidate) < cost(best) - 1e-6);
1142 if better {
1143 best_fit = Some(candidate);
1144 }
1145 }
1146
1147 let Some(BlfCandidate {
1148 rotation,
1149 extent_across,
1150 extent_along,
1151 place_across,
1152 place_along,
1153 opened_row,
1154 }) = best_fit
1155 else {
1156 result.unplaced.push(geom.id().clone());
1157 continue;
1158 };
1159 if opened_row {
1160 row_depth = 0.0;
1161 }
1162
1163 let geom_aabb = geom.aabb_at_rotation(rotation);
1164 let (place_x, place_y) = from_ca(place_across, place_along);
1165 let Some((x, y)) = clamp_placement_to_boundary_with_margin(
1166 place_x - geom_aabb.0[0],
1167 place_y - geom_aabb.0[1],
1168 geom_aabb,
1169 (b_min, b_max),
1170 margin,
1171 ) else {
1172 result.unplaced.push(geom.id().clone());
1173 continue;
1174 };
1175
1176 placements.push(Placement::new_2d(
1177 geom.id().clone(),
1178 instance,
1179 x,
1180 y,
1181 rotation,
1182 ));
1183 total_placed_area += geom.measure();
1184 placed_count += 1;
1185
1186 let (actual_across, actual_along) = to_ca(x + geom_aabb.0[0], y + geom_aabb.0[1]);
1188 cursor_across = actual_across + extent_across + spacing;
1189 cursor_along = actual_along;
1190 row_depth = row_depth.max(extent_along);
1191
1192 report(
1193 ProgressInfo::new()
1194 .with_phase("BLF Placement")
1195 .with_items(placed_count, total_pieces)
1196 .with_utilization(total_placed_area / boundary.measure())
1197 .with_elapsed(start.elapsed_ms()),
1198 );
1199 }
1200 }
1201
1202 result.placements = placements;
1203 result.boundaries_used = 1;
1204 result.utilization = total_placed_area / boundary.measure();
1205 result.computation_time_ms = start.elapsed_ms();
1206
1207 report(
1208 ProgressInfo::new()
1209 .with_phase("Complete")
1210 .with_items(placed_count, total_pieces)
1211 .with_utilization(result.utilization)
1212 .with_elapsed(result.computation_time_ms)
1213 .finished(),
1214 );
1215
1216 Ok(result)
1217 }
1218
1219 pub fn validate(&self, geometries: &[Geometry2D], boundary: &Boundary2D) -> Result<()> {
1228 boundary.validate()?;
1229 self.validate_geometries(geometries)?;
1230 if !self.config.strategy.is_offered(Dimension::TwoD) {
1231 return Err(self.unoffered_strategy());
1232 }
1233 Ok(())
1234 }
1235
1236 fn unoffered_strategy(&self) -> Error {
1238 let strategy = self.config.strategy;
1239 let why = if strategy == Strategy::ExtremePoint {
1240 "is 3D only".to_string()
1241 } else {
1242 "needs the `milp` feature, which this build does not have".to_string()
1243 };
1244 Error::invalid_option(
1245 "strategy",
1246 format!(
1247 "strategy \"{}\" {why}; 2D nesting offers {}",
1248 strategy.name(),
1249 offered_names(Dimension::TwoD)
1250 ),
1251 )
1252 }
1253
1254 fn validate_geometries(&self, geometries: &[Geometry2D]) -> Result<()> {
1264 use u_nesting_core::geometry::Geometry;
1265 if geometries.is_empty() {
1266 return Err(Error::empty_input("geometries"));
1267 }
1268 self.config.validate()?;
1269 for geom in geometries {
1270 geom.validate()?;
1271 }
1272 u_nesting_core::geometry::ensure_unique_ids(geometries)
1273 }
1274
1275 pub fn solve_multi_strip(
1281 &self,
1282 geometries: &[Geometry2D],
1283 boundary: &Boundary2D,
1284 ) -> Result<SolveResult<f64>> {
1285 self.solve_multi_strip_reporting(geometries, boundary, None)
1286 }
1287
1288 pub fn solve_multi_strip_with_progress(
1294 &self,
1295 geometries: &[Geometry2D],
1296 boundary: &Boundary2D,
1297 callback: ProgressCallback,
1298 ) -> Result<SolveResult<f64>> {
1299 self.solve_multi_strip_reporting(geometries, boundary, Some(&callback))
1300 }
1301
1302 fn solve_multi_strip_reporting(
1303 &self,
1304 geometries: &[Geometry2D],
1305 boundary: &Boundary2D,
1306 progress: Option<&ProgressCallback>,
1307 ) -> Result<SolveResult<f64>> {
1308 self.validate(geometries, boundary)?;
1309 self.cancelled.store(false, Ordering::Relaxed);
1310 let start = Timer::now();
1311
1312 let (b_min, b_max) = boundary.aabb();
1313 let strip_width = b_max[0] - b_min[0];
1314
1315 let sheet_area = boundary.measure();
1316 let mut final_result = SolveResult::new();
1317 let mut stop_reason = StopReason::Completed;
1320 let mut remaining_geometries: Vec<Geometry2D> = geometries.to_vec();
1321 let mut strip_index = 0;
1322 let max_strips = 100; let mut placed_total: std::collections::HashMap<String, usize> =
1327 std::collections::HashMap::new();
1328
1329 let mut budget_spent = false;
1333
1334 while !remaining_geometries.is_empty() && strip_index < max_strips {
1335 if self.cancelled.load(Ordering::Relaxed) {
1336 stop_reason = StopReason::Cancelled;
1337 break;
1338 }
1339
1340 let left = match self.config.time_limit_ms {
1345 0 => None,
1346 limit => Some(limit.saturating_sub(start.elapsed_ms())),
1347 };
1348 if left == Some(0) {
1349 budget_spent = true;
1350 }
1351 let (sheet_nester, whole_budget) = match left {
1352 _ if budget_spent => (Some(self.constructive_fill()), false),
1353 None => (None, false),
1354 Some(left) => {
1355 let remaining_area: f64 = remaining_geometries
1356 .iter()
1357 .map(|g| g.measure() * g.quantity() as f64)
1358 .sum();
1359 let sheets_ahead = (remaining_area / sheet_area).ceil().max(1.0);
1360 let share = (left as f64 / sheets_ahead).ceil() as u64;
1361 (Some(self.with_time_limit(share)), share >= left)
1362 }
1363 };
1364
1365 let strip_result = sheet_nester
1367 .as_ref()
1368 .unwrap_or(self)
1369 .run_strategy_reporting(&remaining_geometries, boundary, progress)?;
1370 if whole_budget && strip_result.stop_reason == StopReason::TimeLimit {
1373 budget_spent = true;
1374 }
1375 stop_reason = if budget_spent && strip_result.stop_reason != StopReason::Cancelled {
1376 StopReason::TimeLimit
1377 } else {
1378 strip_result.stop_reason
1379 };
1380
1381 let strip_result = validate_and_filter_placements(
1383 strip_result,
1384 &remaining_geometries,
1385 boundary,
1386 self.config.margin,
1387 );
1388
1389 if strip_result.placements.is_empty() {
1390 break;
1393 }
1394
1395 let mut strip_placed: std::collections::HashMap<String, usize> =
1398 std::collections::HashMap::new();
1399
1400 for mut placement in strip_result.placements {
1402 let gid = placement.geometry_id.clone();
1403 let prior = placed_total.get(&gid).copied().unwrap_or(0);
1406 let in_strip = strip_placed.get(&gid).copied().unwrap_or(0);
1407 placement.instance = prior + in_strip;
1408 if !placement.position.is_empty() {
1410 placement.position[0] += strip_index as f64 * strip_width;
1411 }
1412 placement.boundary_index = strip_index;
1413 *strip_placed.entry(gid).or_insert(0) += 1;
1414 final_result.placements.push(placement);
1415 }
1416
1417 for (gid, cnt) in &strip_placed {
1421 *placed_total.entry(gid.clone()).or_insert(0) += cnt;
1422 }
1423 remaining_geometries = remaining_geometries
1424 .into_iter()
1425 .filter_map(|g| {
1426 let placed_here = strip_placed.get(g.id()).copied().unwrap_or(0);
1427 let new_quantity = g.quantity().saturating_sub(placed_here);
1428 if new_quantity == 0 {
1429 None
1430 } else {
1431 Some(g.with_quantity(new_quantity))
1432 }
1433 })
1434 .collect();
1435
1436 strip_index += 1;
1437
1438 if stop_reason == StopReason::Cancelled {
1442 break;
1443 }
1444 }
1445
1446 for g in &remaining_geometries {
1449 final_result.unplaced.push(g.id().clone());
1450 }
1451
1452 final_result.boundaries_used = strip_index;
1453 final_result.stop_reason = stop_reason;
1454 final_result.deduplicate_unplaced();
1455 final_result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
1457
1458 let (b_min, b_max) = boundary.aabb();
1460 let strip_height = b_max[1] - b_min[1]; let mut strip_stats_map: std::collections::HashMap<usize, (f64, f64, usize)> =
1464 std::collections::HashMap::new(); for placement in &final_result.placements {
1467 let strip_idx = placement.boundary_index;
1468 if let Some(geom) = geometries.iter().find(|g| g.id() == &placement.geometry_id) {
1470 use u_nesting_core::geometry::Geometry;
1471 let piece_area = geom.measure();
1472 let rotation = placement.rotation.first().copied().unwrap_or(0.0);
1473 let (_g_min, g_max) = geom.aabb_at_rotation(rotation);
1474 let local_x = placement.position[0] - (strip_idx as f64 * strip_width);
1477 let right_edge = local_x + g_max[0];
1478
1479 let entry = strip_stats_map.entry(strip_idx).or_insert((0.0, 0.0, 0));
1480 entry.0 = entry.0.max(right_edge); entry.1 += piece_area; entry.2 += 1; }
1484 }
1485
1486 use u_nesting_core::result::StripStats;
1488 let mut strip_stats: Vec<StripStats> = strip_stats_map
1489 .into_iter()
1490 .map(|(idx, (used_length, piece_area, count))| StripStats {
1491 strip_index: idx,
1492 used_length,
1493 piece_area,
1494 piece_count: count,
1495 strip_width, strip_height, })
1498 .collect();
1499 strip_stats.sort_by_key(|s| s.strip_index);
1500
1501 let total_piece_area: f64 = strip_stats.iter().map(|s| s.piece_area).sum();
1504 let total_material_used: f64 = strip_stats
1505 .iter()
1506 .map(|s| s.strip_height * s.used_length)
1507 .sum();
1508
1509 final_result.strip_stats = strip_stats;
1510 final_result.total_piece_area = total_piece_area;
1511 final_result.total_material_used = total_material_used;
1512
1513 if total_material_used > 0.0 {
1514 final_result.utilization = total_piece_area / total_material_used;
1515 }
1516 final_result.computation_time_ms = start.elapsed_ms();
1517
1518 Ok(final_result)
1519 }
1520}
1521
1522impl Nester2D {
1523 fn solve_reporting(
1527 &self,
1528 geometries: &[Geometry2D],
1529 boundary: &Boundary2D,
1530 progress: Option<ProgressCallback>,
1531 ) -> Result<SolveResult<f64>> {
1532 self.validate(geometries, boundary)?;
1533
1534 self.cancelled.store(false, Ordering::Relaxed);
1536 let start = Timer::now();
1537
1538 let initial_result =
1539 self.run_strategy_reporting(geometries, boundary, progress.as_ref())?;
1540
1541 let mut result = validate_and_filter_placements(
1543 initial_result,
1544 geometries,
1545 boundary,
1546 self.config.margin,
1547 );
1548
1549 result.record_unplaced(geometries);
1553 result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
1556 result.computation_time_ms = start.elapsed_ms();
1559 Ok(result)
1560 }
1561}
1562
1563impl Solver for Nester2D {
1564 type Geometry = Geometry2D;
1565 type Boundary = Boundary2D;
1566 type Scalar = f64;
1567
1568 fn solve(
1569 &self,
1570 geometries: &[Self::Geometry],
1571 boundary: &Self::Boundary,
1572 ) -> Result<SolveResult<f64>> {
1573 self.solve_reporting(geometries, boundary, None)
1574 }
1575
1576 fn solve_with_progress(
1577 &self,
1578 geometries: &[Self::Geometry],
1579 boundary: &Self::Boundary,
1580 callback: ProgressCallback,
1581 ) -> Result<SolveResult<f64>> {
1582 self.solve_reporting(geometries, boundary, Some(callback))
1583 }
1584
1585 fn cancel(&self) {
1586 self.cancelled.store(true, Ordering::Relaxed);
1587 }
1588}
1589
1590#[cfg(test)]
1591mod tests {
1592 #[cfg(feature = "milp")]
1593 mod milp_budget {
1594 use super::super::{milp_grid_step, milp_time_limit};
1595 use crate::{Boundary2D, Geometry2D};
1596
1597 #[test]
1600 fn an_unlimited_request_does_not_become_a_minute() {
1601 assert_eq!(milp_time_limit(0), u64::MAX / 2);
1602 }
1603
1604 #[test]
1607 fn a_short_request_is_kept_short() {
1608 assert_eq!(milp_time_limit(1_000), 1_000);
1609 assert_eq!(milp_time_limit(250), 250);
1610 }
1611
1612 #[test]
1616 fn the_grid_step_follows_the_smallest_piece() {
1617 let boundary = Boundary2D::rectangle(400.0, 100.0);
1618 let pieces = [
1619 Geometry2D::rectangle("wide", 80.0, 40.0),
1620 Geometry2D::rectangle("small", 20.0, 60.0),
1621 ];
1622 assert_eq!(milp_grid_step(&pieces, &boundary), 5.0);
1624 }
1625
1626 #[test]
1629 fn a_huge_sheet_keeps_the_step_from_getting_too_fine() {
1630 let boundary = Boundary2D::rectangle(100_000.0, 100.0);
1631 let pieces = [Geometry2D::rectangle("small", 4.0, 4.0)];
1632 assert_eq!(milp_grid_step(&pieces, &boundary), 100_000.0 / 256.0);
1633 }
1634
1635 #[test]
1637 fn the_step_never_goes_below_a_unit() {
1638 let boundary = Boundary2D::rectangle(10.0, 10.0);
1639 let pieces = [Geometry2D::rectangle("tiny", 0.5, 0.5)];
1640 assert_eq!(milp_grid_step(&pieces, &boundary), 1.0);
1641 }
1642 }
1643
1644 use super::*;
1645 use crate::placement_utils::polygon_centroid;
1646
1647 #[test]
1651 fn a_geometry_id_given_twice_is_refused_by_every_entry_point() {
1652 let geometries = [
1653 Geometry2D::rectangle("P", 10.0, 10.0),
1654 Geometry2D::rectangle("Q", 10.0, 10.0),
1655 Geometry2D::rectangle("P", 30.0, 5.0),
1656 ];
1657 let boundary = Boundary2D::rectangle(100.0, 100.0);
1658 let nester = Nester2D::default_config();
1659 let errors = [
1660 nester.solve(&geometries, &boundary).expect_err("solve"),
1661 nester
1662 .solve_multi_strip(&geometries, &boundary)
1663 .expect_err("solve_multi_strip"),
1664 nester
1665 .solve_with_progress(&geometries, &boundary, Box::new(|_| {}))
1666 .expect_err("solve_with_progress"),
1667 ];
1668 for err in errors {
1669 let msg = err.to_string();
1670 assert!(msg.contains("'P'"), "{msg}");
1671 assert!(msg.contains("positions 0 and 2"), "{msg}");
1672 }
1673 }
1674
1675 #[test]
1678 fn an_empty_list_of_geometries_is_refused_by_every_entry_point() {
1679 let boundary = Boundary2D::rectangle(100.0, 100.0);
1680 let nester = Nester2D::default_config();
1681 let errors = [
1682 nester.solve(&[], &boundary).expect_err("solve"),
1683 nester
1684 .solve_multi_strip(&[], &boundary)
1685 .expect_err("solve_multi_strip"),
1686 nester
1687 .solve_with_progress(&[], &boundary, Box::new(|_| {}))
1688 .expect_err("solve_with_progress"),
1689 ];
1690 for err in errors {
1691 assert_eq!(
1692 err,
1693 Error::EmptyInput {
1694 parameter: "geometries".into()
1695 }
1696 );
1697 }
1698 }
1699
1700 #[test]
1701 fn the_seed_reaches_the_searches_that_build_their_own_run() {
1702 let seeded = Nester2D::new(Config::default().with_seed(7));
1706 assert_eq!(seeded.gdrr_config(1000).seed, Some(7));
1707 assert_eq!(seeded.alns_config(1000).seed, Some(7));
1708 let unseeded = Nester2D::default_config();
1709 assert_eq!(unseeded.gdrr_config(1000).seed, None);
1710 assert_eq!(unseeded.alns_config(1000).seed, None);
1711 }
1712
1713 #[test]
1718 fn a_set_budget_is_run_and_an_unset_one_is_the_strategy_s() {
1719 assert_eq!((SEARCH_POPULATION, SEARCH_GENERATIONS), (30, 50));
1720 let unset = Nester2D::default_config();
1721 assert_eq!(unset.ga_config().population_size, SEARCH_POPULATION);
1722 assert_eq!(unset.ga_config().max_generations, SEARCH_GENERATIONS);
1723 assert_eq!(unset.brkga_config().population_size, SEARCH_POPULATION);
1724 assert_eq!(unset.brkga_config().max_generations, SEARCH_GENERATIONS);
1725
1726 let set = Nester2D::new(
1727 Config::default()
1728 .with_population_size(100)
1729 .with_max_generations(200),
1730 );
1731 assert_eq!(set.ga_config().population_size, 100);
1732 assert_eq!(set.ga_config().max_generations, 200);
1733 assert_eq!(set.brkga_config().population_size, 100);
1734 assert_eq!(set.brkga_config().max_generations, 200);
1735
1736 let small = Nester2D::new(Config::default().with_population_size(3));
1737 assert_eq!(small.brkga_config().population_size, 3, "was raised to 4");
1738 assert!(small.brkga_config().validate().is_ok());
1739 }
1740
1741 #[test]
1744 fn the_generation_budget_reaches_the_running_search() {
1745 use std::sync::Mutex;
1746 let reports = |config: Config| {
1747 let seen = Arc::new(Mutex::new(Vec::new()));
1748 let sink = seen.clone();
1749 Nester2D::new(
1750 config
1751 .with_strategy(Strategy::GeneticAlgorithm)
1752 .with_seed(7),
1753 )
1754 .solve_with_progress(
1755 &[Geometry2D::rectangle("R", 10.0, 10.0).with_quantity(4)],
1756 &Boundary2D::rectangle(50.0, 50.0),
1757 Box::new(move |info| {
1759 if info.phase == "Genetic Algorithm" {
1760 sink.lock()
1761 .expect("no report panicked")
1762 .push((info.iteration, info.total_iterations))
1763 }
1764 }),
1765 )
1766 .expect("solve");
1767 let seen = seen.lock().expect("no report panicked").clone();
1768 seen
1769 };
1770 let set = reports(Config::default().with_max_generations(80));
1771 assert!(!set.is_empty(), "the search reports its progress");
1772 assert!(
1773 set.iter().all(|&(at, total)| total == 80 && at <= 80),
1774 "{set:?}"
1775 );
1776 let unset = reports(Config::default());
1777 assert!(!unset.is_empty());
1778 assert!(unset.iter().all(|&(_, total)| total == 50), "{unset:?}");
1779 }
1780
1781 #[test]
1789 fn a_progress_callback_does_not_change_the_solve() {
1790 let geometries = [
1791 Geometry2D::rectangle("A", 30.0, 12.0)
1792 .with_quantity(3)
1793 .with_rotations_deg(vec![0.0, 90.0]),
1794 Geometry2D::rectangle("B", 18.0, 18.0).with_quantity(2),
1795 Geometry2D::new("L")
1796 .with_polygon(vec![
1797 (0.0, 0.0),
1798 (20.0, 0.0),
1799 (20.0, 8.0),
1800 (8.0, 8.0),
1801 (8.0, 20.0),
1802 (0.0, 20.0),
1803 ])
1804 .with_quantity(2)
1805 .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0]),
1806 ];
1807 let boundary = Boundary2D::rectangle(60.0, 50.0);
1808 type Laid = (String, usize, Vec<f64>, Vec<f64>, bool);
1810 let layout = |r: &SolveResult<f64>| -> Vec<Laid> {
1811 r.placements
1812 .iter()
1813 .map(|p| {
1814 (
1815 p.geometry_id.clone(),
1816 p.instance,
1817 p.position.clone(),
1818 p.rotation.clone(),
1819 p.mirrored,
1820 )
1821 })
1822 .collect()
1823 };
1824 for strategy in [
1825 Strategy::BottomLeftFill,
1826 Strategy::NfpGuided,
1827 Strategy::GeneticAlgorithm,
1828 Strategy::Brkga,
1829 ] {
1830 for time_limit_ms in [0, 60_000] {
1831 let config = Config::default()
1832 .with_strategy(strategy)
1833 .with_seed(11)
1834 .with_population_size(6)
1835 .with_max_generations(4)
1836 .with_time_limit(time_limit_ms);
1837 let plain = Nester2D::new(config.clone())
1838 .solve(&geometries, &boundary)
1839 .expect("solve");
1840 let reported = Nester2D::new(config)
1841 .solve_with_progress(&geometries, &boundary, Box::new(|_| {}))
1842 .expect("solve with progress");
1843 let case = format!("{strategy:?}, time_limit_ms {time_limit_ms}");
1844 assert!(!plain.placements.is_empty(), "{case}");
1845 assert_eq!(layout(&plain), layout(&reported), "{case}");
1846 assert_eq!(plain.utilization, reported.utilization, "{case}");
1847 assert_eq!(plain.generations, reported.generations, "{case}");
1848 assert_eq!(plain.unplaced, reported.unplaced, "{case}");
1849 }
1850 }
1851 }
1852
1853 #[test]
1854 fn test_simple_nesting() {
1855 let geometries = vec![
1856 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(3),
1857 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
1858 ];
1859
1860 let boundary = Boundary2D::rectangle(100.0, 50.0);
1861 let nester = Nester2D::default_config();
1862
1863 let result = nester.solve(&geometries, &boundary).unwrap();
1864
1865 assert!(result.utilization > 0.0);
1866 assert!(result.placements.len() <= 5); }
1868
1869 #[test]
1870 fn test_placement_within_bounds() {
1871 let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
1872
1873 let boundary = Boundary2D::rectangle(50.0, 50.0);
1874 let config = Config::default().with_margin(5.0).with_spacing(2.0);
1875 let nester = Nester2D::new(config);
1876
1877 let result = nester.solve(&geometries, &boundary).unwrap();
1878
1879 assert_eq!(result.placements.len(), 4);
1881 assert!(result.unplaced.is_empty());
1882
1883 for p in &result.placements {
1885 assert!(p.position[0] >= 5.0);
1886 assert!(p.position[1] >= 5.0);
1887 }
1888 }
1889
1890 #[test]
1891 fn test_nfp_guided_basic() {
1892 let geometries = vec![
1893 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
1894 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(1),
1895 ];
1896
1897 let boundary = Boundary2D::rectangle(100.0, 50.0);
1898 let config = Config::default().with_strategy(Strategy::NfpGuided);
1899 let nester = Nester2D::new(config);
1900
1901 let result = nester.solve(&geometries, &boundary).unwrap();
1902
1903 assert!(result.utilization > 0.0);
1904 assert_eq!(result.placements.len(), 3); assert!(result.unplaced.is_empty());
1906 }
1907
1908 #[test]
1909 fn test_nfp_guided_with_spacing() {
1910 let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
1911
1912 let boundary = Boundary2D::rectangle(50.0, 50.0);
1913 let config = Config::default()
1914 .with_strategy(Strategy::NfpGuided)
1915 .with_margin(2.0)
1916 .with_spacing(3.0);
1917 let nester = Nester2D::new(config);
1918
1919 let result = nester.solve(&geometries, &boundary).unwrap();
1920
1921 assert_eq!(result.placements.len(), 4);
1923 assert!(result.unplaced.is_empty());
1924
1925 assert!(result.utilization > 0.0);
1927 }
1928
1929 #[test]
1930 fn test_nfp_guided_no_overlap() {
1931 let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(3)];
1932
1933 let boundary = Boundary2D::rectangle(100.0, 100.0);
1934 let config = Config::default().with_strategy(Strategy::NfpGuided);
1935 let nester = Nester2D::new(config);
1936
1937 let result = nester.solve(&geometries, &boundary).unwrap();
1938
1939 assert_eq!(result.placements.len(), 3);
1940
1941 for i in 0..result.placements.len() {
1943 for j in (i + 1)..result.placements.len() {
1944 let p1 = &result.placements[i];
1945 let p2 = &result.placements[j];
1946
1947 let r1_min_x = p1.position[0];
1949 let r1_max_x = p1.position[0] + 20.0;
1950 let r1_min_y = p1.position[1];
1951 let r1_max_y = p1.position[1] + 20.0;
1952
1953 let r2_min_x = p2.position[0];
1954 let r2_max_x = p2.position[0] + 20.0;
1955 let r2_min_y = p2.position[1];
1956 let r2_max_y = p2.position[1] + 20.0;
1957
1958 let overlaps_x = r1_min_x < r2_max_x - 0.01 && r1_max_x > r2_min_x + 0.01;
1960 let overlaps_y = r1_min_y < r2_max_y - 0.01 && r1_max_y > r2_min_y + 0.01;
1961
1962 assert!(
1963 !(overlaps_x && overlaps_y),
1964 "Placements {} and {} overlap",
1965 i,
1966 j
1967 );
1968 }
1969 }
1970 }
1971
1972 fn polygons_overlap(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
1977 for i in 0..a.len() {
1978 let a1 = a[i];
1979 let a2 = a[(i + 1) % a.len()];
1980 for j in 0..b.len() {
1981 let b1 = b[j];
1982 let b2 = b[(j + 1) % b.len()];
1983 if crate::polygon_ops::segments_intersect(a1, a2, b1, b2) {
1984 return true;
1985 }
1986 }
1987 }
1988 false
1989 }
1990
1991 #[test]
1992 fn test_mirror_candidates_reflects_allow_flip() {
1993 let plain = Geometry2D::rectangle("R", 10.0, 10.0);
1994 assert_eq!(mirror_candidates(&plain), &[false]);
1995
1996 let flippable = Geometry2D::rectangle("R", 10.0, 10.0).with_flip(true);
1997 assert_eq!(mirror_candidates(&flippable), &[false, true]);
1998 }
1999
2000 #[test]
2020 fn test_mirror_aware_placement_avoids_overlap() {
2021 let spacing = 1.0;
2029 let geom = Geometry2D::l_shape("L", 30.0, 20.0, 20.0, 10.0);
2030 let boundary_polygon = vec![(0.0, 0.0), (65.0, 0.0), (65.0, 45.0), (0.0, 45.0)];
2031 let cache = NfpCache::new();
2032
2033 let ifp1 =
2035 compute_ifp_with_margin_and_mirror(&boundary_polygon, &geom, 0.0, 0.0, false).unwrap();
2036 let (x1, y1) = find_bottom_left_placement(&ifp1, &[], 1.0, PackingAxis::X)
2037 .expect("first piece must fit");
2038 let placed1 = PlacedGeometry::new(geom.clone(), (x1, y1), 0.0).with_mirrored(false);
2039
2040 let ifp2 =
2042 compute_ifp_with_margin_and_mirror(&boundary_polygon, &geom, 0.0, 0.0, true).unwrap();
2043 let nfp_at_origin = cache
2044 .get_or_compute_mirrored(("L", "L", 0.0, false, true), || {
2045 compute_nfp_mirrored(&placed1.geometry, &geom, 0.0, false, true)
2046 })
2047 .unwrap();
2048 let translated_nfp = translate_nfp(&nfp_at_origin, placed1.position);
2049 let expanded_nfp = offset_nfp(&translated_nfp, spacing);
2050 let (x2, y2) = find_bottom_left_placement(&ifp2, &[&expanded_nfp], 1.0, PackingAxis::X)
2051 .expect("mirrored second piece must fit avoiding the first");
2052 let placed2 = PlacedGeometry::new(geom.clone(), (x2, y2), 0.0).with_mirrored(true);
2053
2054 assert_ne!(
2055 (x1, y1),
2056 (x2, y2),
2057 "mirrored placement must actually avoid the first piece's position"
2058 );
2059 assert!(
2060 !polygons_overlap(
2061 &placed1.translated_exterior(),
2062 &placed2.translated_exterior()
2063 ),
2064 "unmirrored piece at {:?} and mirrored piece at {:?} must not overlap",
2065 (x1, y1),
2066 (x2, y2)
2067 );
2068 }
2069
2070 #[test]
2071 fn test_nfp_guided_utilization() {
2072 let geometries = vec![Geometry2D::rectangle("R1", 25.0, 25.0).with_quantity(4)];
2074
2075 let boundary = Boundary2D::rectangle(100.0, 50.0);
2076 let config = Config::default().with_strategy(Strategy::NfpGuided);
2077 let nester = Nester2D::new(config);
2078
2079 let result = nester.solve(&geometries, &boundary).unwrap();
2080
2081 assert_eq!(result.placements.len(), 4);
2083
2084 assert!(result.utilization > 0.45);
2086 }
2087
2088 #[test]
2089 fn test_polygon_centroid() {
2090 let square = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
2092 let (cx, cy) = polygon_centroid(&square);
2093 assert!((cx - 5.0).abs() < 0.01);
2094 assert!((cy - 5.0).abs() < 0.01);
2095
2096 let triangle = vec![(0.0, 0.0), (6.0, 0.0), (3.0, 6.0)];
2097 let (cx, cy) = polygon_centroid(&triangle);
2098 assert!((cx - 3.0).abs() < 0.01);
2099 assert!((cy - 2.0).abs() < 0.01);
2100 }
2101
2102 #[test]
2103 fn test_ga_strategy_basic() {
2104 let geometries = vec![
2105 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
2106 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
2107 ];
2108
2109 let boundary = Boundary2D::rectangle(100.0, 50.0);
2110 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
2111 let nester = Nester2D::new(config);
2112
2113 let result = nester.solve(&geometries, &boundary).unwrap();
2114
2115 assert!(result.utilization > 0.0);
2116 assert!(!result.placements.is_empty());
2117 assert!(result.generations.is_some());
2119 assert!(result.best_fitness.is_some());
2120 assert!(result.strategy == Some("GeneticAlgorithm".to_string()));
2121 }
2122
2123 #[test]
2124 fn test_ga_strategy_all_placed() {
2125 let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
2127
2128 let boundary = Boundary2D::rectangle(100.0, 100.0);
2129 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
2130 let nester = Nester2D::new(config);
2131
2132 let result = nester.solve(&geometries, &boundary).unwrap();
2133
2134 assert_eq!(result.placements.len(), 4);
2136 assert!(result.unplaced.is_empty());
2137 }
2138
2139 #[test]
2140 fn test_brkga_strategy_basic() {
2141 let geometries = vec![
2142 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
2143 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
2144 ];
2145
2146 let boundary = Boundary2D::rectangle(100.0, 50.0);
2147 let config = Config::default().with_strategy(Strategy::Brkga);
2148 let nester = Nester2D::new(config);
2149
2150 let result = nester.solve(&geometries, &boundary).unwrap();
2151
2152 assert!(result.utilization > 0.0);
2153 assert!(!result.placements.is_empty());
2154 assert!(result.generations.is_some());
2156 assert!(result.best_fitness.is_some());
2157 assert!(result.strategy == Some("BRKGA".to_string()));
2158 }
2159
2160 #[test]
2161 fn test_brkga_strategy_all_placed() {
2162 let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
2164
2165 let boundary = Boundary2D::rectangle(100.0, 100.0);
2166 let config = Config::default()
2168 .with_strategy(Strategy::Brkga)
2169 .with_time_limit(30000); let nester = Nester2D::new(config);
2171
2172 let result = nester.solve(&geometries, &boundary).unwrap();
2173
2174 assert!(
2177 result.placements.len() >= 3,
2178 "Expected at least 3 placements, got {}",
2179 result.placements.len()
2180 );
2181 }
2182
2183 #[test]
2184 fn test_gdrr_strategy_basic() {
2185 let geometries = vec![
2186 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
2187 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
2188 ];
2189
2190 let boundary = Boundary2D::rectangle(100.0, 50.0);
2191 let config = Config::default().with_strategy(Strategy::Gdrr);
2192 let nester = Nester2D::new(config);
2193
2194 let result = nester.solve(&geometries, &boundary).unwrap();
2195
2196 assert!(result.utilization > 0.0);
2197 assert!(!result.placements.is_empty());
2198 assert!(result.iterations.is_some());
2200 assert!(result.best_fitness.is_some());
2201 assert!(result.strategy == Some("GDRR".to_string()));
2202 }
2203
2204 #[test]
2205 fn test_gdrr_strategy_all_placed() {
2206 let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
2208
2209 let boundary = Boundary2D::rectangle(100.0, 100.0);
2210 let config = Config::default().with_strategy(Strategy::Gdrr);
2211 let nester = Nester2D::new(config);
2212
2213 let result = nester.solve(&geometries, &boundary).unwrap();
2214
2215 assert_eq!(result.placements.len(), 4);
2217 assert!(result.unplaced.is_empty());
2218 }
2219
2220 #[test]
2221 fn test_alns_strategy_basic() {
2222 let geometries = vec![
2223 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
2224 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
2225 ];
2226
2227 let boundary = Boundary2D::rectangle(100.0, 50.0);
2228 let config = Config::default().with_strategy(Strategy::Alns);
2229 let nester = Nester2D::new(config);
2230
2231 let result = nester.solve(&geometries, &boundary).unwrap();
2232
2233 assert!(result.utilization > 0.0);
2234 assert!(!result.placements.is_empty());
2235 assert!(result.iterations.is_some());
2237 assert!(result.best_fitness.is_some());
2238 assert!(result.strategy == Some("ALNS".to_string()));
2239 }
2240
2241 #[test]
2242 fn test_alns_strategy_all_placed() {
2243 let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
2245
2246 let boundary = Boundary2D::rectangle(100.0, 100.0);
2247 let config = Config::default().with_strategy(Strategy::Alns);
2248 let nester = Nester2D::new(config);
2249
2250 let result = nester.solve(&geometries, &boundary).unwrap();
2251
2252 assert_eq!(result.placements.len(), 4);
2254 assert!(result.unplaced.is_empty());
2255 }
2256
2257 #[test]
2258 fn test_blf_rotation_optimization() {
2259 let geometries = vec![Geometry2D::rectangle("R1", 30.0, 10.0)
2262 .with_rotations(vec![0.0, std::f64::consts::FRAC_PI_2]) .with_quantity(3)];
2264
2265 let boundary = Boundary2D::rectangle(35.0, 95.0);
2268 let nester = Nester2D::default_config();
2269
2270 let result = nester.solve(&geometries, &boundary).unwrap();
2271
2272 assert_eq!(
2274 result.placements.len(),
2275 3,
2276 "All pieces should be placed with rotation optimization"
2277 );
2278 assert!(result.unplaced.is_empty());
2279 }
2280
2281 #[test]
2282 fn test_blf_selects_best_rotation() {
2283 let geometries = vec![Geometry2D::rectangle("R1", 40.0, 10.0)
2285 .with_rotations(vec![0.0, std::f64::consts::FRAC_PI_2]) .with_quantity(2)];
2287
2288 let boundary = Boundary2D::rectangle(45.0, 50.0);
2292 let nester = Nester2D::default_config();
2293
2294 let result = nester.solve(&geometries, &boundary).unwrap();
2295
2296 assert_eq!(result.placements.len(), 2);
2297 assert!(result.unplaced.is_empty());
2298 }
2299
2300 #[test]
2301 fn test_progress_callback_blf() {
2302 use std::sync::atomic::{AtomicUsize, Ordering};
2303 use std::sync::Arc;
2304
2305 let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
2306 let boundary = Boundary2D::rectangle(50.0, 50.0);
2307 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2308 let nester = Nester2D::new(config);
2309
2310 let callback_count = Arc::new(AtomicUsize::new(0));
2311 let callback_count_clone = callback_count.clone();
2312 let last_items_placed = Arc::new(AtomicUsize::new(0));
2313 let last_items_placed_clone = last_items_placed.clone();
2314
2315 let callback: ProgressCallback = Box::new(move |info| {
2316 callback_count_clone.fetch_add(1, Ordering::Relaxed);
2317 last_items_placed_clone.store(info.items_placed, Ordering::Relaxed);
2318 });
2319
2320 let result = nester
2321 .solve_with_progress(&geometries, &boundary, callback)
2322 .unwrap();
2323
2324 let count = callback_count.load(Ordering::Relaxed);
2326 assert!(
2327 count >= 5,
2328 "Expected at least 5 callbacks (1 initial + 4 pieces + 1 final), got {}",
2329 count
2330 );
2331
2332 let final_placed = last_items_placed.load(Ordering::Relaxed);
2334 assert_eq!(final_placed, 4, "Should report 4 items placed");
2335
2336 assert_eq!(result.placements.len(), 4);
2338 }
2339
2340 #[test]
2341 fn test_progress_callback_nfp() {
2342 use std::sync::atomic::{AtomicUsize, Ordering};
2343 use std::sync::Arc;
2344
2345 let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(2)];
2346 let boundary = Boundary2D::rectangle(50.0, 50.0);
2347 let config = Config::default().with_strategy(Strategy::NfpGuided);
2348 let nester = Nester2D::new(config);
2349
2350 let callback_count = Arc::new(AtomicUsize::new(0));
2351 let callback_count_clone = callback_count.clone();
2352
2353 let callback: ProgressCallback = Box::new(move |info| {
2354 callback_count_clone.fetch_add(1, Ordering::Relaxed);
2355 assert!(info.items_placed <= info.total_items);
2356 });
2357
2358 let result = nester
2359 .solve_with_progress(&geometries, &boundary, callback)
2360 .unwrap();
2361
2362 let count = callback_count.load(Ordering::Relaxed);
2364 assert!(count >= 3, "Expected at least 3 callbacks, got {}", count);
2365
2366 assert_eq!(result.placements.len(), 2);
2368 }
2369
2370 #[test]
2371 fn test_time_limit_honored() {
2372 let geometries: Vec<Geometry2D> = (0..100)
2374 .map(|i| Geometry2D::rectangle(format!("R{}", i), 5.0, 5.0))
2375 .collect();
2376 let boundary = Boundary2D::rectangle(1000.0, 1000.0);
2377
2378 let config = Config::default()
2380 .with_strategy(Strategy::BottomLeftFill)
2381 .with_time_limit(1);
2382 let nester = Nester2D::new(config);
2383
2384 let result = nester.solve(&geometries, &boundary).unwrap();
2385
2386 assert!(
2389 result.computation_time_ms <= 100, "Computation took too long: {}ms (expected <= 100ms with 1ms limit)",
2391 result.computation_time_ms
2392 );
2393 }
2394
2395 #[test]
2396 fn test_time_limit_zero_unlimited() {
2397 let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
2399 let boundary = Boundary2D::rectangle(50.0, 50.0);
2400
2401 let config = Config::default()
2402 .with_strategy(Strategy::BottomLeftFill)
2403 .with_time_limit(0); let nester = Nester2D::new(config);
2405
2406 let result = nester.solve(&geometries, &boundary).unwrap();
2407
2408 assert_eq!(result.placements.len(), 4);
2410 }
2411
2412 #[test]
2413 fn test_blf_bounds_clamping() {
2414 let gear_like = Geometry2D::new("gear")
2418 .with_polygon(vec![
2419 (50.0, 5.0), (65.0, 15.0),
2421 (77.0, 18.0),
2422 (80.0, 32.0),
2423 (95.0, 50.0), (80.0, 68.0),
2425 (77.0, 82.0),
2426 (65.0, 85.0),
2427 (50.0, 95.0), (35.0, 85.0),
2429 (23.0, 82.0),
2430 (20.0, 68.0),
2431 (5.0, 50.0), (20.0, 32.0),
2433 (23.0, 18.0),
2434 (35.0, 15.0),
2435 ])
2436 .with_quantity(1);
2437
2438 let boundary = Boundary2D::rectangle(100.0, 100.0);
2440
2441 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2442 let nester = Nester2D::new(config);
2443
2444 let result = nester
2445 .solve(std::slice::from_ref(&gear_like), &boundary)
2446 .unwrap();
2447
2448 assert_eq!(result.placements.len(), 1);
2449 let placement = &result.placements[0];
2450
2451 let origin_x = placement.position[0];
2453 let origin_y = placement.position[1];
2454
2455 let rotation = placement.rotation.first().copied().unwrap_or(0.0);
2457
2458 let (g_min, g_max) = gear_like.aabb_at_rotation(rotation);
2460
2461 let actual_min_x = origin_x + g_min[0];
2463 let actual_max_x = origin_x + g_max[0];
2464 let actual_min_y = origin_y + g_min[1];
2465 let actual_max_y = origin_y + g_max[1];
2466
2467 assert!(
2469 actual_min_x >= 0.0,
2470 "Left edge {} should be >= 0",
2471 actual_min_x
2472 );
2473 assert!(
2474 actual_max_x <= 100.0,
2475 "Right edge {} should be <= 100",
2476 actual_max_x
2477 );
2478 assert!(
2479 actual_min_y >= 0.0,
2480 "Bottom edge {} should be >= 0",
2481 actual_min_y
2482 );
2483 assert!(
2484 actual_max_y <= 100.0,
2485 "Top edge {} should be <= 100",
2486 actual_max_y
2487 );
2488 }
2489
2490 #[test]
2491 fn test_blf_bounds_clamping_many_pieces() {
2492 let gear_like = Geometry2D::new("gear")
2495 .with_polygon(vec![
2496 (50.0, 5.0),
2497 (65.0, 15.0),
2498 (77.0, 18.0),
2499 (80.0, 32.0),
2500 (95.0, 50.0),
2501 (80.0, 68.0),
2502 (77.0, 82.0),
2503 (65.0, 85.0),
2504 (50.0, 95.0),
2505 (35.0, 85.0),
2506 (23.0, 82.0),
2507 (20.0, 68.0),
2508 (5.0, 50.0),
2509 (20.0, 32.0),
2510 (23.0, 18.0),
2511 (35.0, 15.0),
2512 ])
2513 .with_quantity(13); let boundary = Boundary2D::rectangle(500.0, 500.0);
2517
2518 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2519 let nester = Nester2D::new(config);
2520
2521 let result = nester
2522 .solve(std::slice::from_ref(&gear_like), &boundary)
2523 .unwrap();
2524
2525 for (i, placement) in result.placements.iter().enumerate() {
2527 let origin_x = placement.position[0];
2528 let origin_y = placement.position[1];
2529 let rotation = placement.rotation.first().copied().unwrap_or(0.0);
2530
2531 let (g_min, g_max) = gear_like.aabb_at_rotation(rotation);
2532
2533 let actual_min_x = origin_x + g_min[0];
2534 let actual_max_x = origin_x + g_max[0];
2535 let actual_min_y = origin_y + g_min[1];
2536 let actual_max_y = origin_y + g_max[1];
2537
2538 assert!(
2539 actual_min_x >= -0.01,
2540 "Piece {}: Left edge {} should be >= 0",
2541 i,
2542 actual_min_x
2543 );
2544 assert!(
2545 actual_max_x <= 500.01,
2546 "Piece {}: Right edge {} should be <= 500",
2547 i,
2548 actual_max_x
2549 );
2550 assert!(
2551 actual_min_y >= -0.01,
2552 "Piece {}: Bottom edge {} should be >= 0",
2553 i,
2554 actual_min_y
2555 );
2556 assert!(
2557 actual_max_y <= 500.01,
2558 "Piece {}: Top edge {} should be <= 500",
2559 i,
2560 actual_max_y
2561 );
2562 }
2563 }
2564
2565 #[test]
2566 fn test_blf_bounds_trace() {
2567 let gear = Geometry2D::new("gear").with_polygon(vec![
2569 (50.0, 5.0),
2570 (65.0, 15.0),
2571 (77.0, 18.0),
2572 (80.0, 32.0),
2573 (95.0, 50.0),
2574 (80.0, 68.0),
2575 (77.0, 82.0),
2576 (65.0, 85.0),
2577 (50.0, 95.0),
2578 (35.0, 85.0),
2579 (23.0, 82.0),
2580 (20.0, 68.0),
2581 (5.0, 50.0),
2582 (20.0, 32.0),
2583 (23.0, 18.0),
2584 (35.0, 15.0),
2585 ]);
2586
2587 let (g_min, g_max) = gear.aabb();
2589 println!("Gear AABB: min={:?}, max={:?}", g_min, g_max);
2590 assert!(
2591 (g_min[0] - 5.0).abs() < 0.01,
2592 "g_min[0] should be 5, got {}",
2593 g_min[0]
2594 );
2595 assert!(
2596 (g_max[0] - 95.0).abs() < 0.01,
2597 "g_max[0] should be 95, got {}",
2598 g_max[0]
2599 );
2600
2601 let b_max_x = 500.0;
2603 let margin = 0.0;
2604 let max_valid_x = b_max_x - margin - g_max[0];
2605 println!(
2606 "max_valid_x = {} - {} - {} = {}",
2607 b_max_x, margin, g_max[0], max_valid_x
2608 );
2609 assert!(
2610 (max_valid_x - 405.0).abs() < 0.01,
2611 "max_valid_x should be 405, got {}",
2612 max_valid_x
2613 );
2614
2615 let boundary = Boundary2D::rectangle(500.0, 500.0);
2617 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2618 let nester = Nester2D::new(config);
2619
2620 let result = nester
2621 .solve(&[gear.clone().with_quantity(1)], &boundary)
2622 .unwrap();
2623
2624 assert_eq!(result.placements.len(), 1);
2625 let p = &result.placements[0];
2626 let origin_x = p.position[0];
2627 let rotation = p.rotation.first().copied().unwrap_or(0.0);
2628
2629 let (g_min_r, g_max_r) = gear.aabb_at_rotation(rotation);
2630 let actual_max_x = origin_x + g_max_r[0];
2631
2632 println!("Placement: origin_x={}, rotation={}", origin_x, rotation);
2633 println!(
2634 "At rotation {}: g_min={:?}, g_max={:?}",
2635 rotation, g_min_r, g_max_r
2636 );
2637 println!(
2638 "Actual max x: {} + {} = {}",
2639 origin_x, g_max_r[0], actual_max_x
2640 );
2641
2642 assert!(
2643 actual_max_x <= 500.01,
2644 "Geometry exceeds boundary: max_x={} > 500",
2645 actual_max_x
2646 );
2647 }
2648
2649 #[test]
2650 fn test_blf_bounds_many_pieces_direct() {
2651 let gear = Geometry2D::new("gear")
2653 .with_polygon(vec![
2654 (50.0, 5.0),
2655 (65.0, 15.0),
2656 (77.0, 18.0),
2657 (80.0, 32.0),
2658 (95.0, 50.0),
2659 (80.0, 68.0),
2660 (77.0, 82.0),
2661 (65.0, 85.0),
2662 (50.0, 95.0),
2663 (35.0, 85.0),
2664 (23.0, 82.0),
2665 (20.0, 68.0),
2666 (5.0, 50.0),
2667 (20.0, 32.0),
2668 (23.0, 18.0),
2669 (35.0, 15.0),
2670 ])
2671 .with_quantity(25); let boundary = Boundary2D::rectangle(500.0, 500.0);
2674 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2675 let nester = Nester2D::new(config);
2676
2677 let result = nester
2678 .solve(std::slice::from_ref(&gear), &boundary)
2679 .unwrap();
2680
2681 println!("Placed {} pieces", result.placements.len());
2682
2683 for (i, p) in result.placements.iter().enumerate() {
2685 let origin_x = p.position[0];
2686 let origin_y = p.position[1];
2687 let rotation = p.rotation.first().copied().unwrap_or(0.0);
2688
2689 let (g_min_r, g_max_r) = gear.aabb_at_rotation(rotation);
2690
2691 let actual_min_x = origin_x + g_min_r[0];
2692 let actual_max_x = origin_x + g_max_r[0];
2693 let actual_min_y = origin_y + g_min_r[1];
2694 let actual_max_y = origin_y + g_max_r[1];
2695
2696 println!(
2697 "Piece {}: origin=({:.1}, {:.1}), rot={:.2}, bounds=[{:.1},{:.1}]x[{:.1},{:.1}]",
2698 i,
2699 origin_x,
2700 origin_y,
2701 rotation,
2702 actual_min_x,
2703 actual_max_x,
2704 actual_min_y,
2705 actual_max_y
2706 );
2707
2708 assert!(
2709 actual_max_x <= 500.01,
2710 "Piece {}: Right edge {} > 500",
2711 i,
2712 actual_max_x
2713 );
2714 assert!(
2715 actual_max_y <= 500.01,
2716 "Piece {}: Top edge {} > 500",
2717 i,
2718 actual_max_y
2719 );
2720 }
2721 }
2722
2723 #[test]
2724 fn test_blf_bounds_multi_strip() {
2725 let gear = Geometry2D::new("gear")
2727 .with_polygon(vec![
2728 (50.0, 5.0),
2729 (65.0, 15.0),
2730 (77.0, 18.0),
2731 (80.0, 32.0),
2732 (95.0, 50.0),
2733 (80.0, 68.0),
2734 (77.0, 82.0),
2735 (65.0, 85.0),
2736 (50.0, 95.0),
2737 (35.0, 85.0),
2738 (23.0, 82.0),
2739 (20.0, 68.0),
2740 (5.0, 50.0),
2741 (20.0, 32.0),
2742 (23.0, 18.0),
2743 (35.0, 15.0),
2744 ])
2745 .with_quantity(50); let boundary = Boundary2D::rectangle(500.0, 500.0);
2748 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2749 let nester = Nester2D::new(config);
2750
2751 let result = nester
2753 .solve_multi_strip(std::slice::from_ref(&gear), &boundary)
2754 .unwrap();
2755
2756 println!(
2757 "Placed {} pieces across {} strips",
2758 result.placements.len(),
2759 result.boundaries_used
2760 );
2761
2762 let strip_width = 500.0;
2764 for (i, p) in result.placements.iter().enumerate() {
2765 let origin_x = p.position[0];
2766 let origin_y = p.position[1];
2767 let rotation = p.rotation.first().copied().unwrap_or(0.0);
2768 let strip_idx = p.boundary_index;
2769
2770 let local_x = origin_x - (strip_idx as f64 * strip_width);
2772
2773 let (_g_min_r, g_max_r) = gear.aabb_at_rotation(rotation);
2774
2775 let local_max_x = local_x + g_max_r[0];
2776 let local_max_y = origin_y + g_max_r[1];
2777
2778 println!(
2779 "Piece {}: strip={}, origin=({:.1}, {:.1}), local_x={:.1}, rot={:.2}, local_max_x={:.1}",
2780 i, strip_idx, origin_x, origin_y, local_x, rotation, local_max_x
2781 );
2782
2783 assert!(
2784 local_max_x <= 500.01,
2785 "Piece {}: In strip {}, local right edge {:.1} > 500",
2786 i,
2787 strip_idx,
2788 local_max_x
2789 );
2790 assert!(
2791 local_max_y <= 500.01,
2792 "Piece {}: Top edge {:.1} > 500",
2793 i,
2794 local_max_y
2795 );
2796 }
2797 }
2798
2799 #[test]
2800 fn test_blf_bounds_mixed_shapes() {
2801 let shapes = vec![
2803 Geometry2D::new("shape0")
2805 .with_polygon(vec![
2806 (0.0, 0.0),
2807 (180.0, 0.0),
2808 (195.0, 15.0),
2809 (200.0, 50.0),
2810 (200.0, 150.0),
2811 (195.0, 185.0),
2812 (180.0, 200.0),
2813 (20.0, 200.0),
2814 (5.0, 185.0),
2815 (0.0, 150.0),
2816 (0.0, 50.0),
2817 (5.0, 15.0),
2818 ])
2819 .with_quantity(2),
2820 Geometry2D::new("shape1")
2822 .with_polygon(vec![
2823 (60.0, 0.0),
2824 (85.0, 7.0),
2825 (104.0, 25.0),
2826 (118.0, 50.0),
2827 (120.0, 60.0),
2828 (118.0, 70.0),
2829 (104.0, 95.0),
2830 (85.0, 113.0),
2831 (60.0, 120.0),
2832 (35.0, 113.0),
2833 (16.0, 95.0),
2834 (2.0, 70.0),
2835 (0.0, 60.0),
2836 (2.0, 50.0),
2837 (16.0, 25.0),
2838 (35.0, 7.0),
2839 ])
2840 .with_quantity(4),
2841 Geometry2D::new("shape2")
2843 .with_polygon(vec![
2844 (0.0, 0.0),
2845 (80.0, 0.0),
2846 (80.0, 20.0),
2847 (20.0, 20.0),
2848 (20.0, 80.0),
2849 (0.0, 80.0),
2850 ])
2851 .with_quantity(6),
2852 Geometry2D::new("shape3")
2854 .with_polygon(vec![(0.0, 0.0), (70.0, 0.0), (0.0, 70.0)])
2855 .with_quantity(6),
2856 Geometry2D::new("shape4")
2858 .with_polygon(vec![(0.0, 0.0), (120.0, 0.0), (120.0, 60.0), (0.0, 60.0)])
2859 .with_quantity(4),
2860 Geometry2D::new("shape5")
2862 .with_polygon(vec![
2863 (15.0, 0.0),
2864 (45.0, 0.0),
2865 (60.0, 26.0),
2866 (45.0, 52.0),
2867 (15.0, 52.0),
2868 (0.0, 26.0),
2869 ])
2870 .with_quantity(8),
2871 Geometry2D::new("shape6")
2873 .with_polygon(vec![
2874 (0.0, 0.0),
2875 (90.0, 0.0),
2876 (90.0, 12.0),
2877 (55.0, 12.0),
2878 (55.0, 60.0),
2879 (35.0, 60.0),
2880 (35.0, 12.0),
2881 (0.0, 12.0),
2882 ])
2883 .with_quantity(4),
2884 Geometry2D::new("shape7")
2886 .with_polygon(vec![
2887 (0.0, 10.0),
2888 (10.0, 0.0),
2889 (70.0, 0.0),
2890 (80.0, 10.0),
2891 (80.0, 70.0),
2892 (70.0, 80.0),
2893 (10.0, 80.0),
2894 (0.0, 70.0),
2895 ])
2896 .with_quantity(3),
2897 Geometry2D::new("shape8_gear")
2899 .with_polygon(vec![
2900 (50.0, 5.0),
2901 (65.0, 15.0),
2902 (77.0, 18.0),
2903 (80.0, 32.0),
2904 (95.0, 50.0),
2905 (80.0, 68.0),
2906 (77.0, 82.0),
2907 (65.0, 85.0),
2908 (50.0, 95.0),
2909 (35.0, 85.0),
2910 (23.0, 82.0),
2911 (20.0, 68.0),
2912 (5.0, 50.0),
2913 (20.0, 32.0),
2914 (23.0, 18.0),
2915 (35.0, 15.0),
2916 ])
2917 .with_quantity(13),
2918 ];
2919
2920 let boundary = Boundary2D::rectangle(500.0, 500.0);
2922 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2923 let nester = Nester2D::new(config);
2924
2925 let result = nester.solve_multi_strip(&shapes, &boundary).unwrap();
2926
2927 println!(
2928 "Placed {} pieces across {} strips",
2929 result.placements.len(),
2930 result.boundaries_used
2931 );
2932
2933 let strip_width = 500.0;
2935 let gear_aabb = shapes[8].aabb();
2936 println!("Gear AABB: min={:?}, max={:?}", gear_aabb.0, gear_aabb.1);
2937
2938 let mut violations = Vec::new();
2939 for p in &result.placements {
2940 if p.geometry_id.as_str().starts_with("shape8") {
2941 let origin_x = p.position[0];
2942 let _origin_y = p.position[1];
2943 let rotation = p.rotation.first().copied().unwrap_or(0.0);
2944 let strip_idx = p.boundary_index;
2945 let local_x = origin_x - (strip_idx as f64 * strip_width);
2946
2947 let (_g_min_r, g_max_r) = shapes[8].aabb_at_rotation(rotation);
2948 let local_max_x = local_x + g_max_r[0];
2949
2950 println!(
2951 "{}: strip={}, local_x={:.1}, rot={:.2}, local_max_x={:.1}",
2952 p.geometry_id, strip_idx, local_x, rotation, local_max_x
2953 );
2954
2955 if local_max_x > 500.01 {
2956 violations.push((p.geometry_id.clone(), strip_idx, local_x, local_max_x));
2957 }
2958 }
2959 }
2960
2961 assert!(
2962 violations.is_empty(),
2963 "Found {} Gear pieces exceeding boundary: {:?}",
2964 violations.len(),
2965 violations
2966 );
2967 }
2968
2969 #[test]
2970 fn test_blf_bounds_expanded_like_benchmark() {
2971 type ShapeDef = (Vec<(f64, f64)>, usize, Vec<f64>);
2975 let shape_defs: Vec<ShapeDef> = vec![
2976 (
2977 vec![
2978 (0.0, 0.0),
2979 (180.0, 0.0),
2980 (195.0, 15.0),
2981 (200.0, 50.0),
2982 (200.0, 150.0),
2983 (195.0, 185.0),
2984 (180.0, 200.0),
2985 (20.0, 200.0),
2986 (5.0, 185.0),
2987 (0.0, 150.0),
2988 (0.0, 50.0),
2989 (5.0, 15.0),
2990 ],
2991 2,
2992 vec![0.0, 90.0, 180.0, 270.0],
2993 ),
2994 (
2995 vec![
2996 (60.0, 0.0),
2997 (85.0, 7.0),
2998 (104.0, 25.0),
2999 (118.0, 50.0),
3000 (120.0, 60.0),
3001 (118.0, 70.0),
3002 (104.0, 95.0),
3003 (85.0, 113.0),
3004 (60.0, 120.0),
3005 (35.0, 113.0),
3006 (16.0, 95.0),
3007 (2.0, 70.0),
3008 (0.0, 60.0),
3009 (2.0, 50.0),
3010 (16.0, 25.0),
3011 (35.0, 7.0),
3012 ],
3013 4,
3014 vec![0.0, 45.0, 90.0, 135.0],
3015 ),
3016 (
3017 vec![
3018 (0.0, 0.0),
3019 (80.0, 0.0),
3020 (80.0, 20.0),
3021 (20.0, 20.0),
3022 (20.0, 80.0),
3023 (0.0, 80.0),
3024 ],
3025 6,
3026 vec![0.0, 90.0, 180.0, 270.0],
3027 ),
3028 (
3029 vec![(0.0, 0.0), (70.0, 0.0), (0.0, 70.0)],
3030 6,
3031 vec![0.0, 90.0, 180.0, 270.0],
3032 ),
3033 (
3034 vec![(0.0, 0.0), (120.0, 0.0), (120.0, 60.0), (0.0, 60.0)],
3035 4,
3036 vec![0.0, 90.0],
3037 ),
3038 (
3039 vec![
3040 (15.0, 0.0),
3041 (45.0, 0.0),
3042 (60.0, 26.0),
3043 (45.0, 52.0),
3044 (15.0, 52.0),
3045 (0.0, 26.0),
3046 ],
3047 8,
3048 vec![0.0, 60.0, 120.0],
3049 ),
3050 (
3051 vec![
3052 (0.0, 0.0),
3053 (90.0, 0.0),
3054 (90.0, 12.0),
3055 (55.0, 12.0),
3056 (55.0, 60.0),
3057 (35.0, 60.0),
3058 (35.0, 12.0),
3059 (0.0, 12.0),
3060 ],
3061 4,
3062 vec![0.0, 90.0, 180.0, 270.0],
3063 ),
3064 (
3065 vec![
3066 (0.0, 10.0),
3067 (10.0, 0.0),
3068 (70.0, 0.0),
3069 (80.0, 10.0),
3070 (80.0, 70.0),
3071 (70.0, 80.0),
3072 (10.0, 80.0),
3073 (0.0, 70.0),
3074 ],
3075 3,
3076 vec![0.0, 90.0],
3077 ),
3078 (
3080 vec![
3081 (50.0, 5.0),
3082 (65.0, 15.0),
3083 (77.0, 18.0),
3084 (80.0, 32.0),
3085 (95.0, 50.0),
3086 (80.0, 68.0),
3087 (77.0, 82.0),
3088 (65.0, 85.0),
3089 (50.0, 95.0),
3090 (35.0, 85.0),
3091 (23.0, 82.0),
3092 (20.0, 68.0),
3093 (5.0, 50.0),
3094 (20.0, 32.0),
3095 (23.0, 18.0),
3096 (35.0, 15.0),
3097 ],
3098 13,
3099 vec![0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0],
3100 ),
3101 ];
3102
3103 let mut geometries = Vec::new();
3105 let mut piece_id = 0;
3106 for (vertices, demand, rotations) in shape_defs.iter() {
3107 for _ in 0..*demand {
3108 let geom = Geometry2D::new(format!("piece_{}", piece_id))
3109 .with_polygon(vertices.clone())
3110 .with_rotations_deg(rotations.clone());
3111 geometries.push(geom);
3112 piece_id += 1;
3113 }
3114 }
3115
3116 let gear_geom = Geometry2D::new("gear_check").with_polygon(shape_defs[8].0.clone());
3118 let (gear_min, gear_max) = gear_geom.aabb();
3119 println!("Gear AABB: min={:?}, max={:?}", gear_min, gear_max);
3120
3121 let boundary = Boundary2D::rectangle(500.0, 500.0);
3122 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
3123 let nester = Nester2D::new(config);
3124
3125 let result = nester.solve_multi_strip(&geometries, &boundary).unwrap();
3126
3127 println!(
3128 "Placed {} pieces across {} strips",
3129 result.placements.len(),
3130 result.boundaries_used
3131 );
3132
3133 let strip_width = 500.0;
3135 let mut violations = Vec::new();
3136
3137 for p in &result.placements {
3138 let id_num: usize = p
3139 .geometry_id
3140 .as_str()
3141 .strip_prefix("piece_")
3142 .and_then(|s| s.parse().ok())
3143 .unwrap_or(0);
3144
3145 if (37..=49).contains(&id_num) {
3147 let origin_x = p.position[0];
3148 let rotation = p.rotation.first().copied().unwrap_or(0.0);
3149 let strip_idx = p.boundary_index;
3150 let local_x = origin_x - (strip_idx as f64 * strip_width);
3151
3152 let (_, g_max_r) = gear_geom.aabb_at_rotation(rotation);
3153 let local_max_x = local_x + g_max_r[0];
3154
3155 println!(
3156 "{}: strip={}, local_x={:.1}, rot={:.2}, local_max_x={:.1}",
3157 p.geometry_id, strip_idx, local_x, rotation, local_max_x
3158 );
3159
3160 if local_max_x > 500.01 {
3161 violations.push((p.geometry_id.clone(), strip_idx, local_x, local_max_x));
3162 }
3163 }
3164 }
3165
3166 assert!(
3167 violations.is_empty(),
3168 "Found {} Gear pieces exceeding boundary: {:?}",
3169 violations.len(),
3170 violations
3171 );
3172 }
3173
3174 fn aabbs_overlap(
3176 a_min: [f64; 2],
3177 a_max: [f64; 2],
3178 b_min: [f64; 2],
3179 b_max: [f64; 2],
3180 tolerance: f64,
3181 ) -> bool {
3182 let x_overlap = a_min[0] < b_max[0] - tolerance && a_max[0] > b_min[0] + tolerance;
3184 let y_overlap = a_min[1] < b_max[1] - tolerance && a_max[1] > b_min[1] + tolerance;
3185 x_overlap && y_overlap
3186 }
3187
3188 #[test]
3190 fn test_all_strategies_boundary_and_overlap() {
3191 use std::collections::HashMap;
3192
3193 let shapes = vec![
3195 Geometry2D::new("shape0")
3196 .with_polygon(vec![
3197 (0.0, 0.0),
3198 (180.0, 0.0),
3199 (195.0, 15.0),
3200 (200.0, 50.0),
3201 (200.0, 150.0),
3202 (195.0, 185.0),
3203 (180.0, 200.0),
3204 (20.0, 200.0),
3205 (5.0, 185.0),
3206 (0.0, 150.0),
3207 (0.0, 50.0),
3208 (5.0, 15.0),
3209 ])
3210 .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
3211 .with_quantity(2),
3212 Geometry2D::new("shape1_flange")
3213 .with_polygon(vec![
3214 (60.0, 0.0),
3215 (85.0, 7.0),
3216 (104.0, 25.0),
3217 (118.0, 50.0),
3218 (120.0, 60.0),
3219 (118.0, 70.0),
3220 (104.0, 95.0),
3221 (85.0, 113.0),
3222 (60.0, 120.0),
3223 (35.0, 113.0),
3224 (16.0, 95.0),
3225 (2.0, 70.0),
3226 (0.0, 60.0),
3227 (2.0, 50.0),
3228 (16.0, 25.0),
3229 (35.0, 7.0),
3230 ])
3231 .with_rotations_deg(vec![0.0, 45.0, 90.0, 135.0])
3232 .with_quantity(4),
3233 Geometry2D::new("shape2_lbracket")
3234 .with_polygon(vec![
3235 (0.0, 0.0),
3236 (80.0, 0.0),
3237 (80.0, 20.0),
3238 (20.0, 20.0),
3239 (20.0, 80.0),
3240 (0.0, 80.0),
3241 ])
3242 .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
3243 .with_quantity(6),
3244 Geometry2D::new("shape3_triangle")
3245 .with_polygon(vec![(0.0, 0.0), (70.0, 0.0), (0.0, 70.0)])
3246 .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
3247 .with_quantity(6),
3248 Geometry2D::new("shape4_rect")
3249 .with_polygon(vec![(0.0, 0.0), (120.0, 0.0), (120.0, 60.0), (0.0, 60.0)])
3250 .with_rotations_deg(vec![0.0, 90.0])
3251 .with_quantity(4),
3252 Geometry2D::new("shape5_hexagon")
3253 .with_polygon(vec![
3254 (15.0, 0.0),
3255 (45.0, 0.0),
3256 (60.0, 26.0),
3257 (45.0, 52.0),
3258 (15.0, 52.0),
3259 (0.0, 26.0),
3260 ])
3261 .with_rotations_deg(vec![0.0, 60.0, 120.0])
3262 .with_quantity(8),
3263 Geometry2D::new("shape6_tstiff")
3264 .with_polygon(vec![
3265 (0.0, 0.0),
3266 (90.0, 0.0),
3267 (90.0, 12.0),
3268 (55.0, 12.0),
3269 (55.0, 60.0),
3270 (35.0, 60.0),
3271 (35.0, 12.0),
3272 (0.0, 12.0),
3273 ])
3274 .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
3275 .with_quantity(4),
3276 Geometry2D::new("shape7_mount")
3277 .with_polygon(vec![
3278 (0.0, 10.0),
3279 (10.0, 0.0),
3280 (70.0, 0.0),
3281 (80.0, 10.0),
3282 (80.0, 70.0),
3283 (70.0, 80.0),
3284 (10.0, 80.0),
3285 (0.0, 70.0),
3286 ])
3287 .with_rotations_deg(vec![0.0, 90.0])
3288 .with_quantity(3),
3289 Geometry2D::new("shape8_gear")
3290 .with_polygon(vec![
3291 (50.0, 5.0),
3292 (65.0, 15.0),
3293 (77.0, 18.0),
3294 (80.0, 32.0),
3295 (95.0, 50.0),
3296 (80.0, 68.0),
3297 (77.0, 82.0),
3298 (65.0, 85.0),
3299 (50.0, 95.0),
3300 (35.0, 85.0),
3301 (23.0, 82.0),
3302 (20.0, 68.0),
3303 (5.0, 50.0),
3304 (20.0, 32.0),
3305 (23.0, 18.0),
3306 (35.0, 15.0),
3307 ])
3308 .with_rotations_deg(vec![0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0])
3309 .with_quantity(13),
3310 ];
3311
3312 let geom_map: HashMap<String, &Geometry2D> =
3314 shapes.iter().map(|g| (g.id().clone(), g)).collect();
3315
3316 let boundary = Boundary2D::rectangle(500.0, 500.0);
3317 let strip_width = 500.0;
3318
3319 let strategies = vec![
3321 Strategy::BottomLeftFill,
3322 Strategy::NfpGuided,
3323 Strategy::GeneticAlgorithm,
3324 Strategy::Brkga,
3325 Strategy::SimulatedAnnealing,
3326 Strategy::Gdrr,
3327 Strategy::Alns,
3328 ];
3329
3330 for strategy in strategies {
3331 println!("\n========== Testing {:?} ==========", strategy);
3332
3333 let config = Config::default()
3334 .with_strategy(strategy)
3335 .with_time_limit(30000); let nester = Nester2D::new(config);
3337
3338 let result = match nester.solve_multi_strip(&shapes, &boundary) {
3339 Ok(r) => r,
3340 Err(e) => {
3341 println!(" Strategy {:?} failed: {}", strategy, e);
3342 continue;
3343 }
3344 };
3345
3346 println!(
3347 " Placed {} pieces across {} strips",
3348 result.placements.len(),
3349 result.boundaries_used
3350 );
3351
3352 let mut boundary_violations = Vec::new();
3354 for p in &result.placements {
3355 let base_id = p.geometry_id.split('_').next().unwrap_or(&p.geometry_id);
3357 let full_id = if base_id.starts_with("shape") {
3358 shapes
3360 .iter()
3361 .find(|g| p.geometry_id.starts_with(g.id()))
3362 .map(|g| g.id().as_str())
3363 } else {
3364 geom_map.get(&p.geometry_id).map(|g| g.id().as_str())
3365 };
3366
3367 let geom = match full_id.and_then(|id| geom_map.get(id)) {
3368 Some(g) => *g,
3369 None => {
3370 match shapes.iter().find(|g| p.geometry_id.starts_with(g.id())) {
3372 Some(g) => g,
3373 None => {
3374 println!(
3375 " WARNING: Could not find geometry for {}",
3376 p.geometry_id
3377 );
3378 continue;
3379 }
3380 }
3381 }
3382 };
3383
3384 let origin_x = p.position[0];
3385 let origin_y = p.position[1];
3386 let rotation = p.rotation.first().copied().unwrap_or(0.0);
3387 let strip_idx = p.boundary_index;
3388
3389 let local_x = origin_x - (strip_idx as f64 * strip_width);
3391
3392 let (g_min, g_max) = geom.aabb_at_rotation(rotation);
3393
3394 let local_min_x = local_x + g_min[0];
3396 let local_max_x = local_x + g_max[0];
3397 let local_min_y = origin_y + g_min[1];
3398 let local_max_y = origin_y + g_max[1];
3399
3400 let tolerance = 0.1;
3402 if local_min_x < -tolerance
3403 || local_max_x > 500.0 + tolerance
3404 || local_min_y < -tolerance
3405 || local_max_y > 500.0 + tolerance
3406 {
3407 boundary_violations.push(format!(
3408 "{} in strip {}: bounds ({:.1}, {:.1}) to ({:.1}, {:.1})",
3409 p.geometry_id,
3410 strip_idx,
3411 local_min_x,
3412 local_min_y,
3413 local_max_x,
3414 local_max_y
3415 ));
3416 }
3417 }
3418
3419 if !boundary_violations.is_empty() {
3420 println!(" BOUNDARY VIOLATIONS ({}):", boundary_violations.len());
3421 for v in &boundary_violations {
3422 println!(" - {}", v);
3423 }
3424 }
3425
3426 let mut overlaps = Vec::new();
3428 let placements: Vec<_> = result.placements.iter().collect();
3429
3430 for i in 0..placements.len() {
3431 for j in (i + 1)..placements.len() {
3432 let p1 = placements[i];
3433 let p2 = placements[j];
3434
3435 if p1.boundary_index != p2.boundary_index {
3437 continue;
3438 }
3439
3440 let g1 = shapes.iter().find(|g| p1.geometry_id.starts_with(g.id()));
3442 let g2 = shapes.iter().find(|g| p2.geometry_id.starts_with(g.id()));
3443
3444 let (g1, g2) = match (g1, g2) {
3445 (Some(a), Some(b)) => (a, b),
3446 _ => continue,
3447 };
3448
3449 let strip_idx = p1.boundary_index;
3450 let local_x1 = p1.position[0] - (strip_idx as f64 * strip_width);
3451 let local_x2 = p2.position[0] - (strip_idx as f64 * strip_width);
3452
3453 let rot1 = p1.rotation.first().copied().unwrap_or(0.0);
3454 let rot2 = p2.rotation.first().copied().unwrap_or(0.0);
3455
3456 let (g1_min, g1_max) = g1.aabb_at_rotation(rot1);
3457 let (g2_min, g2_max) = g2.aabb_at_rotation(rot2);
3458
3459 let a_min = [local_x1 + g1_min[0], p1.position[1] + g1_min[1]];
3460 let a_max = [local_x1 + g1_max[0], p1.position[1] + g1_max[1]];
3461 let b_min = [local_x2 + g2_min[0], p2.position[1] + g2_min[1]];
3462 let b_max = [local_x2 + g2_max[0], p2.position[1] + g2_max[1]];
3463
3464 if aabbs_overlap(a_min, a_max, b_min, b_max, 1.0) {
3465 overlaps.push(format!(
3466 "{} and {} in strip {}",
3467 p1.geometry_id, p2.geometry_id, strip_idx
3468 ));
3469 }
3470 }
3471 }
3472
3473 if !overlaps.is_empty() {
3474 println!(" OVERLAPS ({}):", overlaps.len());
3475 for o in overlaps.iter().take(10) {
3476 println!(" - {}", o);
3477 }
3478 if overlaps.len() > 10 {
3479 println!(" ... and {} more", overlaps.len() - 10);
3480 }
3481 }
3482
3483 assert!(
3485 boundary_violations.is_empty(),
3486 "{:?}: Found {} boundary violations",
3487 strategy,
3488 boundary_violations.len()
3489 );
3490
3491 println!(" ✓ All placements within boundary");
3492 println!(" ✓ No AABB overlaps detected");
3493 }
3494 }
3495
3496 #[test]
3504 fn test_multi_strip_distributes_all_instances() {
3505 let geometries = vec![Geometry2D::rectangle("part", 100.0, 100.0).with_quantity(20)];
3506 let boundary = Boundary2D::rectangle(300.0, 300.0);
3507 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
3508 let nester = Nester2D::new(config);
3509
3510 let result = nester.solve_multi_strip(&geometries, &boundary).unwrap();
3511
3512 assert_eq!(
3514 result.placements.len(),
3515 20,
3516 "all 20 instances must be placed"
3517 );
3518 assert_eq!(
3519 result.boundaries_used, 3,
3520 "20 of 100x100 in 300x300 => 3 sheets"
3521 );
3522 assert!(
3523 result.unplaced.is_empty(),
3524 "nothing should be unplaced, got {:?}",
3525 result.unplaced
3526 );
3527 assert_eq!(result.total_requested, 20);
3529
3530 let mut seen = std::collections::HashSet::new();
3533 for p in &result.placements {
3534 assert!(
3535 seen.insert((p.geometry_id.clone(), p.instance)),
3536 "duplicate (id, instance) = ({}, {}) across sheets",
3537 p.geometry_id,
3538 p.instance
3539 );
3540 }
3541 let mut sheets: Vec<usize> = result.placements.iter().map(|p| p.boundary_index).collect();
3543 sheets.sort_unstable();
3544 sheets.dedup();
3545 assert_eq!(sheets, vec![0, 1, 2]);
3546 }
3547
3548 #[test]
3551 fn test_multi_strip_oversized_reported_unplaced() {
3552 let geometries = vec![
3553 Geometry2D::rectangle("ok", 50.0, 50.0).with_quantity(2),
3554 Geometry2D::rectangle("toobig", 400.0, 400.0).with_quantity(3),
3555 ];
3556 let boundary = Boundary2D::rectangle(300.0, 300.0);
3557 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
3558 let nester = Nester2D::new(config);
3559
3560 let result = nester.solve_multi_strip(&geometries, &boundary).unwrap();
3561
3562 assert_eq!(result.total_requested, 5, "2 + 3 instances requested");
3563 assert_eq!(result.placements.len(), 2);
3565 assert!(
3566 result.unplaced.contains(&"toobig".to_string()),
3567 "oversized geometry must surface in unplaced, got {:?}",
3568 result.unplaced
3569 );
3570 }
3571}