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_rotation_steps(4)
872 .with_grid_step(milp_grid_step(geometries, boundary));
873
874 run_nfp_cm_nesting(
875 geometries,
876 boundary,
877 &self.config,
878 &exact_config,
879 self.cancelled.clone(),
880 )
881 }
882
883 #[cfg(feature = "milp")]
885 fn hybrid_exact(
886 &self,
887 geometries: &[Geometry2D],
888 boundary: &Boundary2D,
889 ) -> Result<SolveResult<f64>> {
890 let started = Timer::now();
891 let total_instances: usize = geometries.iter().map(|g| g.quantity()).sum();
893
894 if total_instances <= 15 {
896 let exact_config = ExactConfig::default()
900 .with_time_limit_ms(milp_time_limit(self.config.time_limit_ms / 2))
901 .with_max_items(15);
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() {
913 return Ok(exact_result);
914 }
915 }
916
917 let budget = match self.config.time_limit_ms {
922 0 => self.search_budget_ms(),
923 limit => limit.saturating_sub(started.elapsed_ms()).max(1),
924 };
925 self.alns_within(geometries, boundary, budget, None)
926 }
927
928 fn bottom_left_fill_impl(
936 &self,
937 geometries: &[Geometry2D],
938 boundary: &Boundary2D,
939 callback: Option<&ProgressCallback>,
940 ) -> Result<SolveResult<f64>> {
941 let start = Timer::now();
942 let mut result = SolveResult::new();
943 let mut placements = Vec::new();
944 let report = |info: ProgressInfo| {
945 if let Some(callback) = callback {
946 callback(info);
947 }
948 };
949
950 let axis = PackingAxis::of(boundary);
951 let to_ca = |x: f64, y: f64| match axis {
953 PackingAxis::Y => (x, y),
954 PackingAxis::X => (y, x),
955 };
956 let from_ca = |across: f64, along: f64| match axis {
957 PackingAxis::Y => (across, along),
958 PackingAxis::X => (along, across),
959 };
960
961 let (b_min, b_max) = boundary.aabb();
962 let margin = self.config.margin;
963 let spacing = self.config.spacing;
964 let (min_across, min_along) = to_ca(b_min[0] + margin, b_min[1] + margin);
965 let (max_across, max_along) = to_ca(b_max[0] - margin, b_max[1] - margin);
966
967 let hole_boxes: Vec<(f64, f64, f64, f64)> = boundary
974 .holes()
975 .iter()
976 .filter(|hole| !hole.is_empty())
977 .map(|hole| {
978 let (mut x0, mut y0) = (f64::INFINITY, f64::INFINITY);
979 let (mut x1, mut y1) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
980 for &(x, y) in hole {
981 x0 = x0.min(x);
982 y0 = y0.min(y);
983 x1 = x1.max(x);
984 y1 = y1.max(y);
985 }
986 let (a0, l0) = to_ca(x0 - margin, y0 - margin);
987 let (a1, l1) = to_ca(x1 + margin, y1 + margin);
988 (a0.min(a1), l0.min(l1), a0.max(a1), l0.max(l1))
989 })
990 .collect();
991
992 let blocking_hole_edge = |across: f64, along: f64, ea: f64, el: f64| -> Option<f64> {
996 hole_boxes
997 .iter()
998 .filter(|(a0, l0, a1, l1)| {
999 across < *a1 && across + ea > *a0 && along < *l1 && along + el > *l0
1000 })
1001 .map(|(_, _, a1, _)| *a1)
1002 .fold(None, |far: Option<f64>, a1| {
1003 Some(far.map_or(a1, |f| f.max(a1)))
1004 })
1005 };
1006
1007 let mut cursor_across = min_across;
1008 let mut cursor_along = min_along;
1009 let mut row_depth = 0.0_f64;
1010 let mut total_placed_area = 0.0;
1011 let total_pieces: usize = geometries.iter().map(|g| g.quantity()).sum();
1012 let mut placed_count = 0usize;
1013
1014 report(
1015 ProgressInfo::new()
1016 .with_phase("BLF Placement")
1017 .with_items(0, total_pieces)
1018 .with_elapsed(0),
1019 );
1020
1021 for geom in geometries {
1022 geom.validate()?;
1023
1024 let rotation_angles: Vec<f64> = geom.rotations();
1026
1027 for instance in 0..geom.quantity() {
1028 if let Some(stop) = self.stop_requested(&start) {
1030 result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
1031 result.utilization = total_placed_area / boundary.measure();
1032 result.computation_time_ms = start.elapsed_ms();
1033 result.placements = placements;
1034 result.stop_reason = stop;
1035 report(
1036 ProgressInfo::new()
1037 .with_phase(stop_phase(stop))
1038 .with_items(placed_count, total_pieces)
1039 .with_elapsed(result.computation_time_ms)
1040 .finished(),
1041 );
1042 return Ok(result);
1043 }
1044
1045 let mut best_fit: Option<BlfCandidate> = None;
1048
1049 for &rotation in &rotation_angles {
1050 let (g_min, g_max) = geom.aabb_at_rotation(rotation);
1051 let (extent_across, extent_along) =
1052 to_ca(g_max[0] - g_min[0], g_max[1] - g_min[1]);
1053
1054 if extent_across > max_across - min_across
1055 || extent_along > max_along - min_along
1056 {
1057 continue;
1058 }
1059
1060 let mut place_across = cursor_across;
1061 let mut place_along = cursor_along;
1062 let mut depth = row_depth;
1065 let mut opened_row = false;
1066 let mut turns = 0usize;
1072 let max_turns = 4 * (hole_boxes.len() + 1) * (hole_boxes.len() + 2) + 16;
1073 let fits = loop {
1074 if place_across + extent_across > max_across {
1075 place_across = min_across;
1076 place_along += depth + spacing;
1077 depth = extent_along;
1078 opened_row = true;
1079 }
1080 if place_along + extent_along > max_along {
1081 break false;
1082 }
1083 match blocking_hole_edge(
1084 place_across,
1085 place_along,
1086 extent_across,
1087 extent_along,
1088 ) {
1089 None => break true,
1090 Some(past) => place_across = past + spacing,
1091 }
1092 turns += 1;
1093 if turns > max_turns {
1094 break false;
1095 }
1096 };
1097 if !fits {
1098 continue;
1099 }
1100
1101 let cost = |c: &BlfCandidate| {
1106 if c.opened_row {
1107 c.place_along - min_along + c.extent_along
1108 } else {
1109 c.place_across - min_across + c.extent_across
1110 }
1111 };
1112 let candidate = BlfCandidate {
1113 rotation,
1114 extent_across,
1115 extent_along,
1116 place_across,
1117 place_along,
1118 opened_row,
1119 };
1120 let better = best_fit
1121 .as_ref()
1122 .is_none_or(|best| cost(&candidate) < cost(best) - 1e-6);
1123 if better {
1124 best_fit = Some(candidate);
1125 }
1126 }
1127
1128 let Some(BlfCandidate {
1129 rotation,
1130 extent_across,
1131 extent_along,
1132 place_across,
1133 place_along,
1134 opened_row,
1135 }) = best_fit
1136 else {
1137 result.unplaced.push(geom.id().clone());
1138 continue;
1139 };
1140 if opened_row {
1141 row_depth = 0.0;
1142 }
1143
1144 let geom_aabb = geom.aabb_at_rotation(rotation);
1145 let (place_x, place_y) = from_ca(place_across, place_along);
1146 let Some((x, y)) = clamp_placement_to_boundary_with_margin(
1147 place_x - geom_aabb.0[0],
1148 place_y - geom_aabb.0[1],
1149 geom_aabb,
1150 (b_min, b_max),
1151 margin,
1152 ) else {
1153 result.unplaced.push(geom.id().clone());
1154 continue;
1155 };
1156
1157 placements.push(Placement::new_2d(
1158 geom.id().clone(),
1159 instance,
1160 x,
1161 y,
1162 rotation,
1163 ));
1164 total_placed_area += geom.measure();
1165 placed_count += 1;
1166
1167 let (actual_across, actual_along) = to_ca(x + geom_aabb.0[0], y + geom_aabb.0[1]);
1169 cursor_across = actual_across + extent_across + spacing;
1170 cursor_along = actual_along;
1171 row_depth = row_depth.max(extent_along);
1172
1173 report(
1174 ProgressInfo::new()
1175 .with_phase("BLF Placement")
1176 .with_items(placed_count, total_pieces)
1177 .with_utilization(total_placed_area / boundary.measure())
1178 .with_elapsed(start.elapsed_ms()),
1179 );
1180 }
1181 }
1182
1183 result.placements = placements;
1184 result.boundaries_used = 1;
1185 result.utilization = total_placed_area / boundary.measure();
1186 result.computation_time_ms = start.elapsed_ms();
1187
1188 report(
1189 ProgressInfo::new()
1190 .with_phase("Complete")
1191 .with_items(placed_count, total_pieces)
1192 .with_utilization(result.utilization)
1193 .with_elapsed(result.computation_time_ms)
1194 .finished(),
1195 );
1196
1197 Ok(result)
1198 }
1199
1200 pub fn validate(&self, geometries: &[Geometry2D], boundary: &Boundary2D) -> Result<()> {
1209 boundary.validate()?;
1210 self.validate_geometries(geometries)?;
1211 if !self.config.strategy.is_offered(Dimension::TwoD) {
1212 return Err(self.unoffered_strategy());
1213 }
1214 Ok(())
1215 }
1216
1217 fn unoffered_strategy(&self) -> Error {
1219 let strategy = self.config.strategy;
1220 let why = if strategy == Strategy::ExtremePoint {
1221 "is 3D only".to_string()
1222 } else {
1223 "needs the `milp` feature, which this build does not have".to_string()
1224 };
1225 Error::invalid_option(
1226 "strategy",
1227 format!(
1228 "strategy \"{}\" {why}; 2D nesting offers {}",
1229 strategy.name(),
1230 offered_names(Dimension::TwoD)
1231 ),
1232 )
1233 }
1234
1235 fn validate_geometries(&self, geometries: &[Geometry2D]) -> Result<()> {
1245 use u_nesting_core::geometry::Geometry;
1246 if geometries.is_empty() {
1247 return Err(Error::empty_input("geometries"));
1248 }
1249 self.config.validate()?;
1250 for geom in geometries {
1251 geom.validate()?;
1252 }
1253 u_nesting_core::geometry::ensure_unique_ids(geometries)
1254 }
1255
1256 pub fn solve_multi_strip(
1262 &self,
1263 geometries: &[Geometry2D],
1264 boundary: &Boundary2D,
1265 ) -> Result<SolveResult<f64>> {
1266 self.solve_multi_strip_reporting(geometries, boundary, None)
1267 }
1268
1269 pub fn solve_multi_strip_with_progress(
1275 &self,
1276 geometries: &[Geometry2D],
1277 boundary: &Boundary2D,
1278 callback: ProgressCallback,
1279 ) -> Result<SolveResult<f64>> {
1280 self.solve_multi_strip_reporting(geometries, boundary, Some(&callback))
1281 }
1282
1283 fn solve_multi_strip_reporting(
1284 &self,
1285 geometries: &[Geometry2D],
1286 boundary: &Boundary2D,
1287 progress: Option<&ProgressCallback>,
1288 ) -> Result<SolveResult<f64>> {
1289 self.validate(geometries, boundary)?;
1290 self.cancelled.store(false, Ordering::Relaxed);
1291 let start = Timer::now();
1292
1293 let (b_min, b_max) = boundary.aabb();
1294 let strip_width = b_max[0] - b_min[0];
1295
1296 let sheet_area = boundary.measure();
1297 let mut final_result = SolveResult::new();
1298 let mut stop_reason = StopReason::Completed;
1301 let mut remaining_geometries: Vec<Geometry2D> = geometries.to_vec();
1302 let mut strip_index = 0;
1303 let max_strips = 100; let mut placed_total: std::collections::HashMap<String, usize> =
1308 std::collections::HashMap::new();
1309
1310 let mut budget_spent = false;
1314
1315 while !remaining_geometries.is_empty() && strip_index < max_strips {
1316 if self.cancelled.load(Ordering::Relaxed) {
1317 stop_reason = StopReason::Cancelled;
1318 break;
1319 }
1320
1321 let left = match self.config.time_limit_ms {
1326 0 => None,
1327 limit => Some(limit.saturating_sub(start.elapsed_ms())),
1328 };
1329 if left == Some(0) {
1330 budget_spent = true;
1331 }
1332 let (sheet_nester, whole_budget) = match left {
1333 _ if budget_spent => (Some(self.constructive_fill()), false),
1334 None => (None, false),
1335 Some(left) => {
1336 let remaining_area: f64 = remaining_geometries
1337 .iter()
1338 .map(|g| g.measure() * g.quantity() as f64)
1339 .sum();
1340 let sheets_ahead = (remaining_area / sheet_area).ceil().max(1.0);
1341 let share = (left as f64 / sheets_ahead).ceil() as u64;
1342 (Some(self.with_time_limit(share)), share >= left)
1343 }
1344 };
1345
1346 let strip_result = sheet_nester
1348 .as_ref()
1349 .unwrap_or(self)
1350 .run_strategy_reporting(&remaining_geometries, boundary, progress)?;
1351 if whole_budget && strip_result.stop_reason == StopReason::TimeLimit {
1354 budget_spent = true;
1355 }
1356 stop_reason = if budget_spent && strip_result.stop_reason != StopReason::Cancelled {
1357 StopReason::TimeLimit
1358 } else {
1359 strip_result.stop_reason
1360 };
1361
1362 let strip_result = validate_and_filter_placements(
1364 strip_result,
1365 &remaining_geometries,
1366 boundary,
1367 self.config.margin,
1368 );
1369
1370 if strip_result.placements.is_empty() {
1371 break;
1374 }
1375
1376 let mut strip_placed: std::collections::HashMap<String, usize> =
1379 std::collections::HashMap::new();
1380
1381 for mut placement in strip_result.placements {
1383 let gid = placement.geometry_id.clone();
1384 let prior = placed_total.get(&gid).copied().unwrap_or(0);
1387 let in_strip = strip_placed.get(&gid).copied().unwrap_or(0);
1388 placement.instance = prior + in_strip;
1389 if !placement.position.is_empty() {
1391 placement.position[0] += strip_index as f64 * strip_width;
1392 }
1393 placement.boundary_index = strip_index;
1394 *strip_placed.entry(gid).or_insert(0) += 1;
1395 final_result.placements.push(placement);
1396 }
1397
1398 for (gid, cnt) in &strip_placed {
1402 *placed_total.entry(gid.clone()).or_insert(0) += cnt;
1403 }
1404 remaining_geometries = remaining_geometries
1405 .into_iter()
1406 .filter_map(|g| {
1407 let placed_here = strip_placed.get(g.id()).copied().unwrap_or(0);
1408 let new_quantity = g.quantity().saturating_sub(placed_here);
1409 if new_quantity == 0 {
1410 None
1411 } else {
1412 Some(g.with_quantity(new_quantity))
1413 }
1414 })
1415 .collect();
1416
1417 strip_index += 1;
1418
1419 if stop_reason == StopReason::Cancelled {
1423 break;
1424 }
1425 }
1426
1427 for g in &remaining_geometries {
1430 final_result.unplaced.push(g.id().clone());
1431 }
1432
1433 final_result.boundaries_used = strip_index;
1434 final_result.stop_reason = stop_reason;
1435 final_result.deduplicate_unplaced();
1436 final_result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
1438
1439 let (b_min, b_max) = boundary.aabb();
1441 let strip_height = b_max[1] - b_min[1]; let mut strip_stats_map: std::collections::HashMap<usize, (f64, f64, usize)> =
1445 std::collections::HashMap::new(); for placement in &final_result.placements {
1448 let strip_idx = placement.boundary_index;
1449 if let Some(geom) = geometries.iter().find(|g| g.id() == &placement.geometry_id) {
1451 use u_nesting_core::geometry::Geometry;
1452 let piece_area = geom.measure();
1453 let rotation = placement.rotation.first().copied().unwrap_or(0.0);
1454 let (_g_min, g_max) = geom.aabb_at_rotation(rotation);
1455 let local_x = placement.position[0] - (strip_idx as f64 * strip_width);
1458 let right_edge = local_x + g_max[0];
1459
1460 let entry = strip_stats_map.entry(strip_idx).or_insert((0.0, 0.0, 0));
1461 entry.0 = entry.0.max(right_edge); entry.1 += piece_area; entry.2 += 1; }
1465 }
1466
1467 use u_nesting_core::result::StripStats;
1469 let mut strip_stats: Vec<StripStats> = strip_stats_map
1470 .into_iter()
1471 .map(|(idx, (used_length, piece_area, count))| StripStats {
1472 strip_index: idx,
1473 used_length,
1474 piece_area,
1475 piece_count: count,
1476 strip_width, strip_height, })
1479 .collect();
1480 strip_stats.sort_by_key(|s| s.strip_index);
1481
1482 let total_piece_area: f64 = strip_stats.iter().map(|s| s.piece_area).sum();
1485 let total_material_used: f64 = strip_stats
1486 .iter()
1487 .map(|s| s.strip_height * s.used_length)
1488 .sum();
1489
1490 final_result.strip_stats = strip_stats;
1491 final_result.total_piece_area = total_piece_area;
1492 final_result.total_material_used = total_material_used;
1493
1494 if total_material_used > 0.0 {
1495 final_result.utilization = total_piece_area / total_material_used;
1496 }
1497 final_result.computation_time_ms = start.elapsed_ms();
1498
1499 Ok(final_result)
1500 }
1501}
1502
1503impl Nester2D {
1504 fn solve_reporting(
1508 &self,
1509 geometries: &[Geometry2D],
1510 boundary: &Boundary2D,
1511 progress: Option<ProgressCallback>,
1512 ) -> Result<SolveResult<f64>> {
1513 self.validate(geometries, boundary)?;
1514
1515 self.cancelled.store(false, Ordering::Relaxed);
1517 let start = Timer::now();
1518
1519 let initial_result =
1520 self.run_strategy_reporting(geometries, boundary, progress.as_ref())?;
1521
1522 let mut result = validate_and_filter_placements(
1524 initial_result,
1525 geometries,
1526 boundary,
1527 self.config.margin,
1528 );
1529
1530 result.record_unplaced(geometries);
1534 result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
1537 result.computation_time_ms = start.elapsed_ms();
1540 Ok(result)
1541 }
1542}
1543
1544impl Solver for Nester2D {
1545 type Geometry = Geometry2D;
1546 type Boundary = Boundary2D;
1547 type Scalar = f64;
1548
1549 fn solve(
1550 &self,
1551 geometries: &[Self::Geometry],
1552 boundary: &Self::Boundary,
1553 ) -> Result<SolveResult<f64>> {
1554 self.solve_reporting(geometries, boundary, None)
1555 }
1556
1557 fn solve_with_progress(
1558 &self,
1559 geometries: &[Self::Geometry],
1560 boundary: &Self::Boundary,
1561 callback: ProgressCallback,
1562 ) -> Result<SolveResult<f64>> {
1563 self.solve_reporting(geometries, boundary, Some(callback))
1564 }
1565
1566 fn cancel(&self) {
1567 self.cancelled.store(true, Ordering::Relaxed);
1568 }
1569}
1570
1571#[cfg(test)]
1572mod tests {
1573 #[cfg(feature = "milp")]
1574 mod milp_budget {
1575 use super::super::{milp_grid_step, milp_time_limit};
1576 use crate::{Boundary2D, Geometry2D};
1577
1578 #[test]
1581 fn an_unlimited_request_does_not_become_a_minute() {
1582 assert_eq!(milp_time_limit(0), u64::MAX / 2);
1583 }
1584
1585 #[test]
1588 fn a_short_request_is_kept_short() {
1589 assert_eq!(milp_time_limit(1_000), 1_000);
1590 assert_eq!(milp_time_limit(250), 250);
1591 }
1592
1593 #[test]
1597 fn the_grid_step_follows_the_smallest_piece() {
1598 let boundary = Boundary2D::rectangle(400.0, 100.0);
1599 let pieces = [
1600 Geometry2D::rectangle("wide", 80.0, 40.0),
1601 Geometry2D::rectangle("small", 20.0, 60.0),
1602 ];
1603 assert_eq!(milp_grid_step(&pieces, &boundary), 5.0);
1605 }
1606
1607 #[test]
1610 fn a_huge_sheet_keeps_the_step_from_getting_too_fine() {
1611 let boundary = Boundary2D::rectangle(100_000.0, 100.0);
1612 let pieces = [Geometry2D::rectangle("small", 4.0, 4.0)];
1613 assert_eq!(milp_grid_step(&pieces, &boundary), 100_000.0 / 256.0);
1614 }
1615
1616 #[test]
1618 fn the_step_never_goes_below_a_unit() {
1619 let boundary = Boundary2D::rectangle(10.0, 10.0);
1620 let pieces = [Geometry2D::rectangle("tiny", 0.5, 0.5)];
1621 assert_eq!(milp_grid_step(&pieces, &boundary), 1.0);
1622 }
1623 }
1624
1625 use super::*;
1626 use crate::placement_utils::polygon_centroid;
1627
1628 #[test]
1632 fn a_geometry_id_given_twice_is_refused_by_every_entry_point() {
1633 let geometries = [
1634 Geometry2D::rectangle("P", 10.0, 10.0),
1635 Geometry2D::rectangle("Q", 10.0, 10.0),
1636 Geometry2D::rectangle("P", 30.0, 5.0),
1637 ];
1638 let boundary = Boundary2D::rectangle(100.0, 100.0);
1639 let nester = Nester2D::default_config();
1640 let errors = [
1641 nester.solve(&geometries, &boundary).expect_err("solve"),
1642 nester
1643 .solve_multi_strip(&geometries, &boundary)
1644 .expect_err("solve_multi_strip"),
1645 nester
1646 .solve_with_progress(&geometries, &boundary, Box::new(|_| {}))
1647 .expect_err("solve_with_progress"),
1648 ];
1649 for err in errors {
1650 let msg = err.to_string();
1651 assert!(msg.contains("'P'"), "{msg}");
1652 assert!(msg.contains("positions 0 and 2"), "{msg}");
1653 }
1654 }
1655
1656 #[test]
1659 fn an_empty_list_of_geometries_is_refused_by_every_entry_point() {
1660 let boundary = Boundary2D::rectangle(100.0, 100.0);
1661 let nester = Nester2D::default_config();
1662 let errors = [
1663 nester.solve(&[], &boundary).expect_err("solve"),
1664 nester
1665 .solve_multi_strip(&[], &boundary)
1666 .expect_err("solve_multi_strip"),
1667 nester
1668 .solve_with_progress(&[], &boundary, Box::new(|_| {}))
1669 .expect_err("solve_with_progress"),
1670 ];
1671 for err in errors {
1672 assert_eq!(
1673 err,
1674 Error::EmptyInput {
1675 parameter: "geometries".into()
1676 }
1677 );
1678 }
1679 }
1680
1681 #[test]
1682 fn the_seed_reaches_the_searches_that_build_their_own_run() {
1683 let seeded = Nester2D::new(Config::default().with_seed(7));
1687 assert_eq!(seeded.gdrr_config(1000).seed, Some(7));
1688 assert_eq!(seeded.alns_config(1000).seed, Some(7));
1689 let unseeded = Nester2D::default_config();
1690 assert_eq!(unseeded.gdrr_config(1000).seed, None);
1691 assert_eq!(unseeded.alns_config(1000).seed, None);
1692 }
1693
1694 #[test]
1699 fn a_set_budget_is_run_and_an_unset_one_is_the_strategy_s() {
1700 assert_eq!((SEARCH_POPULATION, SEARCH_GENERATIONS), (30, 50));
1701 let unset = Nester2D::default_config();
1702 assert_eq!(unset.ga_config().population_size, SEARCH_POPULATION);
1703 assert_eq!(unset.ga_config().max_generations, SEARCH_GENERATIONS);
1704 assert_eq!(unset.brkga_config().population_size, SEARCH_POPULATION);
1705 assert_eq!(unset.brkga_config().max_generations, SEARCH_GENERATIONS);
1706
1707 let set = Nester2D::new(
1708 Config::default()
1709 .with_population_size(100)
1710 .with_max_generations(200),
1711 );
1712 assert_eq!(set.ga_config().population_size, 100);
1713 assert_eq!(set.ga_config().max_generations, 200);
1714 assert_eq!(set.brkga_config().population_size, 100);
1715 assert_eq!(set.brkga_config().max_generations, 200);
1716
1717 let small = Nester2D::new(Config::default().with_population_size(3));
1718 assert_eq!(small.brkga_config().population_size, 3, "was raised to 4");
1719 assert!(small.brkga_config().validate().is_ok());
1720 }
1721
1722 #[test]
1725 fn the_generation_budget_reaches_the_running_search() {
1726 use std::sync::Mutex;
1727 let reports = |config: Config| {
1728 let seen = Arc::new(Mutex::new(Vec::new()));
1729 let sink = seen.clone();
1730 Nester2D::new(
1731 config
1732 .with_strategy(Strategy::GeneticAlgorithm)
1733 .with_seed(7),
1734 )
1735 .solve_with_progress(
1736 &[Geometry2D::rectangle("R", 10.0, 10.0).with_quantity(4)],
1737 &Boundary2D::rectangle(50.0, 50.0),
1738 Box::new(move |info| {
1740 if info.phase == "Genetic Algorithm" {
1741 sink.lock()
1742 .expect("no report panicked")
1743 .push((info.iteration, info.total_iterations))
1744 }
1745 }),
1746 )
1747 .expect("solve");
1748 let seen = seen.lock().expect("no report panicked").clone();
1749 seen
1750 };
1751 let set = reports(Config::default().with_max_generations(80));
1752 assert!(!set.is_empty(), "the search reports its progress");
1753 assert!(
1754 set.iter().all(|&(at, total)| total == 80 && at <= 80),
1755 "{set:?}"
1756 );
1757 let unset = reports(Config::default());
1758 assert!(!unset.is_empty());
1759 assert!(unset.iter().all(|&(_, total)| total == 50), "{unset:?}");
1760 }
1761
1762 #[test]
1770 fn a_progress_callback_does_not_change_the_solve() {
1771 let geometries = [
1772 Geometry2D::rectangle("A", 30.0, 12.0)
1773 .with_quantity(3)
1774 .with_rotations_deg(vec![0.0, 90.0]),
1775 Geometry2D::rectangle("B", 18.0, 18.0).with_quantity(2),
1776 Geometry2D::new("L")
1777 .with_polygon(vec![
1778 (0.0, 0.0),
1779 (20.0, 0.0),
1780 (20.0, 8.0),
1781 (8.0, 8.0),
1782 (8.0, 20.0),
1783 (0.0, 20.0),
1784 ])
1785 .with_quantity(2)
1786 .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0]),
1787 ];
1788 let boundary = Boundary2D::rectangle(60.0, 50.0);
1789 type Laid = (String, usize, Vec<f64>, Vec<f64>, bool);
1791 let layout = |r: &SolveResult<f64>| -> Vec<Laid> {
1792 r.placements
1793 .iter()
1794 .map(|p| {
1795 (
1796 p.geometry_id.clone(),
1797 p.instance,
1798 p.position.clone(),
1799 p.rotation.clone(),
1800 p.mirrored,
1801 )
1802 })
1803 .collect()
1804 };
1805 for strategy in [
1806 Strategy::BottomLeftFill,
1807 Strategy::NfpGuided,
1808 Strategy::GeneticAlgorithm,
1809 Strategy::Brkga,
1810 ] {
1811 for time_limit_ms in [0, 60_000] {
1812 let config = Config::default()
1813 .with_strategy(strategy)
1814 .with_seed(11)
1815 .with_population_size(6)
1816 .with_max_generations(4)
1817 .with_time_limit(time_limit_ms);
1818 let plain = Nester2D::new(config.clone())
1819 .solve(&geometries, &boundary)
1820 .expect("solve");
1821 let reported = Nester2D::new(config)
1822 .solve_with_progress(&geometries, &boundary, Box::new(|_| {}))
1823 .expect("solve with progress");
1824 let case = format!("{strategy:?}, time_limit_ms {time_limit_ms}");
1825 assert!(!plain.placements.is_empty(), "{case}");
1826 assert_eq!(layout(&plain), layout(&reported), "{case}");
1827 assert_eq!(plain.utilization, reported.utilization, "{case}");
1828 assert_eq!(plain.generations, reported.generations, "{case}");
1829 assert_eq!(plain.unplaced, reported.unplaced, "{case}");
1830 }
1831 }
1832 }
1833
1834 #[test]
1835 fn test_simple_nesting() {
1836 let geometries = vec![
1837 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(3),
1838 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
1839 ];
1840
1841 let boundary = Boundary2D::rectangle(100.0, 50.0);
1842 let nester = Nester2D::default_config();
1843
1844 let result = nester.solve(&geometries, &boundary).unwrap();
1845
1846 assert!(result.utilization > 0.0);
1847 assert!(result.placements.len() <= 5); }
1849
1850 #[test]
1851 fn test_placement_within_bounds() {
1852 let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
1853
1854 let boundary = Boundary2D::rectangle(50.0, 50.0);
1855 let config = Config::default().with_margin(5.0).with_spacing(2.0);
1856 let nester = Nester2D::new(config);
1857
1858 let result = nester.solve(&geometries, &boundary).unwrap();
1859
1860 assert_eq!(result.placements.len(), 4);
1862 assert!(result.unplaced.is_empty());
1863
1864 for p in &result.placements {
1866 assert!(p.position[0] >= 5.0);
1867 assert!(p.position[1] >= 5.0);
1868 }
1869 }
1870
1871 #[test]
1872 fn test_nfp_guided_basic() {
1873 let geometries = vec![
1874 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
1875 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(1),
1876 ];
1877
1878 let boundary = Boundary2D::rectangle(100.0, 50.0);
1879 let config = Config::default().with_strategy(Strategy::NfpGuided);
1880 let nester = Nester2D::new(config);
1881
1882 let result = nester.solve(&geometries, &boundary).unwrap();
1883
1884 assert!(result.utilization > 0.0);
1885 assert_eq!(result.placements.len(), 3); assert!(result.unplaced.is_empty());
1887 }
1888
1889 #[test]
1890 fn test_nfp_guided_with_spacing() {
1891 let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
1892
1893 let boundary = Boundary2D::rectangle(50.0, 50.0);
1894 let config = Config::default()
1895 .with_strategy(Strategy::NfpGuided)
1896 .with_margin(2.0)
1897 .with_spacing(3.0);
1898 let nester = Nester2D::new(config);
1899
1900 let result = nester.solve(&geometries, &boundary).unwrap();
1901
1902 assert_eq!(result.placements.len(), 4);
1904 assert!(result.unplaced.is_empty());
1905
1906 assert!(result.utilization > 0.0);
1908 }
1909
1910 #[test]
1911 fn test_nfp_guided_no_overlap() {
1912 let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(3)];
1913
1914 let boundary = Boundary2D::rectangle(100.0, 100.0);
1915 let config = Config::default().with_strategy(Strategy::NfpGuided);
1916 let nester = Nester2D::new(config);
1917
1918 let result = nester.solve(&geometries, &boundary).unwrap();
1919
1920 assert_eq!(result.placements.len(), 3);
1921
1922 for i in 0..result.placements.len() {
1924 for j in (i + 1)..result.placements.len() {
1925 let p1 = &result.placements[i];
1926 let p2 = &result.placements[j];
1927
1928 let r1_min_x = p1.position[0];
1930 let r1_max_x = p1.position[0] + 20.0;
1931 let r1_min_y = p1.position[1];
1932 let r1_max_y = p1.position[1] + 20.0;
1933
1934 let r2_min_x = p2.position[0];
1935 let r2_max_x = p2.position[0] + 20.0;
1936 let r2_min_y = p2.position[1];
1937 let r2_max_y = p2.position[1] + 20.0;
1938
1939 let overlaps_x = r1_min_x < r2_max_x - 0.01 && r1_max_x > r2_min_x + 0.01;
1941 let overlaps_y = r1_min_y < r2_max_y - 0.01 && r1_max_y > r2_min_y + 0.01;
1942
1943 assert!(
1944 !(overlaps_x && overlaps_y),
1945 "Placements {} and {} overlap",
1946 i,
1947 j
1948 );
1949 }
1950 }
1951 }
1952
1953 fn polygons_overlap(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
1958 for i in 0..a.len() {
1959 let a1 = a[i];
1960 let a2 = a[(i + 1) % a.len()];
1961 for j in 0..b.len() {
1962 let b1 = b[j];
1963 let b2 = b[(j + 1) % b.len()];
1964 if crate::polygon_ops::segments_intersect(a1, a2, b1, b2) {
1965 return true;
1966 }
1967 }
1968 }
1969 false
1970 }
1971
1972 #[test]
1973 fn test_mirror_candidates_reflects_allow_flip() {
1974 let plain = Geometry2D::rectangle("R", 10.0, 10.0);
1975 assert_eq!(mirror_candidates(&plain), &[false]);
1976
1977 let flippable = Geometry2D::rectangle("R", 10.0, 10.0).with_flip(true);
1978 assert_eq!(mirror_candidates(&flippable), &[false, true]);
1979 }
1980
1981 #[test]
2001 fn test_mirror_aware_placement_avoids_overlap() {
2002 let spacing = 1.0;
2010 let geom = Geometry2D::l_shape("L", 30.0, 20.0, 20.0, 10.0);
2011 let boundary_polygon = vec![(0.0, 0.0), (65.0, 0.0), (65.0, 45.0), (0.0, 45.0)];
2012 let cache = NfpCache::new();
2013
2014 let ifp1 =
2016 compute_ifp_with_margin_and_mirror(&boundary_polygon, &geom, 0.0, 0.0, false).unwrap();
2017 let (x1, y1) = find_bottom_left_placement(&ifp1, &[], 1.0, PackingAxis::X)
2018 .expect("first piece must fit");
2019 let placed1 = PlacedGeometry::new(geom.clone(), (x1, y1), 0.0).with_mirrored(false);
2020
2021 let ifp2 =
2023 compute_ifp_with_margin_and_mirror(&boundary_polygon, &geom, 0.0, 0.0, true).unwrap();
2024 let nfp_at_origin = cache
2025 .get_or_compute_mirrored(("L", "L", 0.0, false, true), || {
2026 compute_nfp_mirrored(&placed1.geometry, &geom, 0.0, false, true)
2027 })
2028 .unwrap();
2029 let translated_nfp = translate_nfp(&nfp_at_origin, placed1.position);
2030 let expanded_nfp = offset_nfp(&translated_nfp, spacing);
2031 let (x2, y2) = find_bottom_left_placement(&ifp2, &[&expanded_nfp], 1.0, PackingAxis::X)
2032 .expect("mirrored second piece must fit avoiding the first");
2033 let placed2 = PlacedGeometry::new(geom.clone(), (x2, y2), 0.0).with_mirrored(true);
2034
2035 assert_ne!(
2036 (x1, y1),
2037 (x2, y2),
2038 "mirrored placement must actually avoid the first piece's position"
2039 );
2040 assert!(
2041 !polygons_overlap(
2042 &placed1.translated_exterior(),
2043 &placed2.translated_exterior()
2044 ),
2045 "unmirrored piece at {:?} and mirrored piece at {:?} must not overlap",
2046 (x1, y1),
2047 (x2, y2)
2048 );
2049 }
2050
2051 #[test]
2052 fn test_nfp_guided_utilization() {
2053 let geometries = vec![Geometry2D::rectangle("R1", 25.0, 25.0).with_quantity(4)];
2055
2056 let boundary = Boundary2D::rectangle(100.0, 50.0);
2057 let config = Config::default().with_strategy(Strategy::NfpGuided);
2058 let nester = Nester2D::new(config);
2059
2060 let result = nester.solve(&geometries, &boundary).unwrap();
2061
2062 assert_eq!(result.placements.len(), 4);
2064
2065 assert!(result.utilization > 0.45);
2067 }
2068
2069 #[test]
2070 fn test_polygon_centroid() {
2071 let square = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
2073 let (cx, cy) = polygon_centroid(&square);
2074 assert!((cx - 5.0).abs() < 0.01);
2075 assert!((cy - 5.0).abs() < 0.01);
2076
2077 let triangle = vec![(0.0, 0.0), (6.0, 0.0), (3.0, 6.0)];
2078 let (cx, cy) = polygon_centroid(&triangle);
2079 assert!((cx - 3.0).abs() < 0.01);
2080 assert!((cy - 2.0).abs() < 0.01);
2081 }
2082
2083 #[test]
2084 fn test_ga_strategy_basic() {
2085 let geometries = vec![
2086 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
2087 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
2088 ];
2089
2090 let boundary = Boundary2D::rectangle(100.0, 50.0);
2091 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
2092 let nester = Nester2D::new(config);
2093
2094 let result = nester.solve(&geometries, &boundary).unwrap();
2095
2096 assert!(result.utilization > 0.0);
2097 assert!(!result.placements.is_empty());
2098 assert!(result.generations.is_some());
2100 assert!(result.best_fitness.is_some());
2101 assert!(result.strategy == Some("GeneticAlgorithm".to_string()));
2102 }
2103
2104 #[test]
2105 fn test_ga_strategy_all_placed() {
2106 let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
2108
2109 let boundary = Boundary2D::rectangle(100.0, 100.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_eq!(result.placements.len(), 4);
2117 assert!(result.unplaced.is_empty());
2118 }
2119
2120 #[test]
2121 fn test_brkga_strategy_basic() {
2122 let geometries = vec![
2123 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
2124 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
2125 ];
2126
2127 let boundary = Boundary2D::rectangle(100.0, 50.0);
2128 let config = Config::default().with_strategy(Strategy::Brkga);
2129 let nester = Nester2D::new(config);
2130
2131 let result = nester.solve(&geometries, &boundary).unwrap();
2132
2133 assert!(result.utilization > 0.0);
2134 assert!(!result.placements.is_empty());
2135 assert!(result.generations.is_some());
2137 assert!(result.best_fitness.is_some());
2138 assert!(result.strategy == Some("BRKGA".to_string()));
2139 }
2140
2141 #[test]
2142 fn test_brkga_strategy_all_placed() {
2143 let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
2145
2146 let boundary = Boundary2D::rectangle(100.0, 100.0);
2147 let config = Config::default()
2149 .with_strategy(Strategy::Brkga)
2150 .with_time_limit(30000); let nester = Nester2D::new(config);
2152
2153 let result = nester.solve(&geometries, &boundary).unwrap();
2154
2155 assert!(
2158 result.placements.len() >= 3,
2159 "Expected at least 3 placements, got {}",
2160 result.placements.len()
2161 );
2162 }
2163
2164 #[test]
2165 fn test_gdrr_strategy_basic() {
2166 let geometries = vec![
2167 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
2168 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
2169 ];
2170
2171 let boundary = Boundary2D::rectangle(100.0, 50.0);
2172 let config = Config::default().with_strategy(Strategy::Gdrr);
2173 let nester = Nester2D::new(config);
2174
2175 let result = nester.solve(&geometries, &boundary).unwrap();
2176
2177 assert!(result.utilization > 0.0);
2178 assert!(!result.placements.is_empty());
2179 assert!(result.iterations.is_some());
2181 assert!(result.best_fitness.is_some());
2182 assert!(result.strategy == Some("GDRR".to_string()));
2183 }
2184
2185 #[test]
2186 fn test_gdrr_strategy_all_placed() {
2187 let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
2189
2190 let boundary = Boundary2D::rectangle(100.0, 100.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_eq!(result.placements.len(), 4);
2198 assert!(result.unplaced.is_empty());
2199 }
2200
2201 #[test]
2202 fn test_alns_strategy_basic() {
2203 let geometries = vec![
2204 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
2205 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
2206 ];
2207
2208 let boundary = Boundary2D::rectangle(100.0, 50.0);
2209 let config = Config::default().with_strategy(Strategy::Alns);
2210 let nester = Nester2D::new(config);
2211
2212 let result = nester.solve(&geometries, &boundary).unwrap();
2213
2214 assert!(result.utilization > 0.0);
2215 assert!(!result.placements.is_empty());
2216 assert!(result.iterations.is_some());
2218 assert!(result.best_fitness.is_some());
2219 assert!(result.strategy == Some("ALNS".to_string()));
2220 }
2221
2222 #[test]
2223 fn test_alns_strategy_all_placed() {
2224 let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
2226
2227 let boundary = Boundary2D::rectangle(100.0, 100.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_eq!(result.placements.len(), 4);
2235 assert!(result.unplaced.is_empty());
2236 }
2237
2238 #[test]
2239 fn test_blf_rotation_optimization() {
2240 let geometries = vec![Geometry2D::rectangle("R1", 30.0, 10.0)
2243 .with_rotations(vec![0.0, std::f64::consts::FRAC_PI_2]) .with_quantity(3)];
2245
2246 let boundary = Boundary2D::rectangle(35.0, 95.0);
2249 let nester = Nester2D::default_config();
2250
2251 let result = nester.solve(&geometries, &boundary).unwrap();
2252
2253 assert_eq!(
2255 result.placements.len(),
2256 3,
2257 "All pieces should be placed with rotation optimization"
2258 );
2259 assert!(result.unplaced.is_empty());
2260 }
2261
2262 #[test]
2263 fn test_blf_selects_best_rotation() {
2264 let geometries = vec![Geometry2D::rectangle("R1", 40.0, 10.0)
2266 .with_rotations(vec![0.0, std::f64::consts::FRAC_PI_2]) .with_quantity(2)];
2268
2269 let boundary = Boundary2D::rectangle(45.0, 50.0);
2273 let nester = Nester2D::default_config();
2274
2275 let result = nester.solve(&geometries, &boundary).unwrap();
2276
2277 assert_eq!(result.placements.len(), 2);
2278 assert!(result.unplaced.is_empty());
2279 }
2280
2281 #[test]
2282 fn test_progress_callback_blf() {
2283 use std::sync::atomic::{AtomicUsize, Ordering};
2284 use std::sync::Arc;
2285
2286 let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
2287 let boundary = Boundary2D::rectangle(50.0, 50.0);
2288 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2289 let nester = Nester2D::new(config);
2290
2291 let callback_count = Arc::new(AtomicUsize::new(0));
2292 let callback_count_clone = callback_count.clone();
2293 let last_items_placed = Arc::new(AtomicUsize::new(0));
2294 let last_items_placed_clone = last_items_placed.clone();
2295
2296 let callback: ProgressCallback = Box::new(move |info| {
2297 callback_count_clone.fetch_add(1, Ordering::Relaxed);
2298 last_items_placed_clone.store(info.items_placed, Ordering::Relaxed);
2299 });
2300
2301 let result = nester
2302 .solve_with_progress(&geometries, &boundary, callback)
2303 .unwrap();
2304
2305 let count = callback_count.load(Ordering::Relaxed);
2307 assert!(
2308 count >= 5,
2309 "Expected at least 5 callbacks (1 initial + 4 pieces + 1 final), got {}",
2310 count
2311 );
2312
2313 let final_placed = last_items_placed.load(Ordering::Relaxed);
2315 assert_eq!(final_placed, 4, "Should report 4 items placed");
2316
2317 assert_eq!(result.placements.len(), 4);
2319 }
2320
2321 #[test]
2322 fn test_progress_callback_nfp() {
2323 use std::sync::atomic::{AtomicUsize, Ordering};
2324 use std::sync::Arc;
2325
2326 let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(2)];
2327 let boundary = Boundary2D::rectangle(50.0, 50.0);
2328 let config = Config::default().with_strategy(Strategy::NfpGuided);
2329 let nester = Nester2D::new(config);
2330
2331 let callback_count = Arc::new(AtomicUsize::new(0));
2332 let callback_count_clone = callback_count.clone();
2333
2334 let callback: ProgressCallback = Box::new(move |info| {
2335 callback_count_clone.fetch_add(1, Ordering::Relaxed);
2336 assert!(info.items_placed <= info.total_items);
2337 });
2338
2339 let result = nester
2340 .solve_with_progress(&geometries, &boundary, callback)
2341 .unwrap();
2342
2343 let count = callback_count.load(Ordering::Relaxed);
2345 assert!(count >= 3, "Expected at least 3 callbacks, got {}", count);
2346
2347 assert_eq!(result.placements.len(), 2);
2349 }
2350
2351 #[test]
2352 fn test_time_limit_honored() {
2353 let geometries: Vec<Geometry2D> = (0..100)
2355 .map(|i| Geometry2D::rectangle(format!("R{}", i), 5.0, 5.0))
2356 .collect();
2357 let boundary = Boundary2D::rectangle(1000.0, 1000.0);
2358
2359 let config = Config::default()
2361 .with_strategy(Strategy::BottomLeftFill)
2362 .with_time_limit(1);
2363 let nester = Nester2D::new(config);
2364
2365 let result = nester.solve(&geometries, &boundary).unwrap();
2366
2367 assert!(
2370 result.computation_time_ms <= 100, "Computation took too long: {}ms (expected <= 100ms with 1ms limit)",
2372 result.computation_time_ms
2373 );
2374 }
2375
2376 #[test]
2377 fn test_time_limit_zero_unlimited() {
2378 let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
2380 let boundary = Boundary2D::rectangle(50.0, 50.0);
2381
2382 let config = Config::default()
2383 .with_strategy(Strategy::BottomLeftFill)
2384 .with_time_limit(0); let nester = Nester2D::new(config);
2386
2387 let result = nester.solve(&geometries, &boundary).unwrap();
2388
2389 assert_eq!(result.placements.len(), 4);
2391 }
2392
2393 #[test]
2394 fn test_blf_bounds_clamping() {
2395 let gear_like = Geometry2D::new("gear")
2399 .with_polygon(vec![
2400 (50.0, 5.0), (65.0, 15.0),
2402 (77.0, 18.0),
2403 (80.0, 32.0),
2404 (95.0, 50.0), (80.0, 68.0),
2406 (77.0, 82.0),
2407 (65.0, 85.0),
2408 (50.0, 95.0), (35.0, 85.0),
2410 (23.0, 82.0),
2411 (20.0, 68.0),
2412 (5.0, 50.0), (20.0, 32.0),
2414 (23.0, 18.0),
2415 (35.0, 15.0),
2416 ])
2417 .with_quantity(1);
2418
2419 let boundary = Boundary2D::rectangle(100.0, 100.0);
2421
2422 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2423 let nester = Nester2D::new(config);
2424
2425 let result = nester
2426 .solve(std::slice::from_ref(&gear_like), &boundary)
2427 .unwrap();
2428
2429 assert_eq!(result.placements.len(), 1);
2430 let placement = &result.placements[0];
2431
2432 let origin_x = placement.position[0];
2434 let origin_y = placement.position[1];
2435
2436 let rotation = placement.rotation.first().copied().unwrap_or(0.0);
2438
2439 let (g_min, g_max) = gear_like.aabb_at_rotation(rotation);
2441
2442 let actual_min_x = origin_x + g_min[0];
2444 let actual_max_x = origin_x + g_max[0];
2445 let actual_min_y = origin_y + g_min[1];
2446 let actual_max_y = origin_y + g_max[1];
2447
2448 assert!(
2450 actual_min_x >= 0.0,
2451 "Left edge {} should be >= 0",
2452 actual_min_x
2453 );
2454 assert!(
2455 actual_max_x <= 100.0,
2456 "Right edge {} should be <= 100",
2457 actual_max_x
2458 );
2459 assert!(
2460 actual_min_y >= 0.0,
2461 "Bottom edge {} should be >= 0",
2462 actual_min_y
2463 );
2464 assert!(
2465 actual_max_y <= 100.0,
2466 "Top edge {} should be <= 100",
2467 actual_max_y
2468 );
2469 }
2470
2471 #[test]
2472 fn test_blf_bounds_clamping_many_pieces() {
2473 let gear_like = Geometry2D::new("gear")
2476 .with_polygon(vec![
2477 (50.0, 5.0),
2478 (65.0, 15.0),
2479 (77.0, 18.0),
2480 (80.0, 32.0),
2481 (95.0, 50.0),
2482 (80.0, 68.0),
2483 (77.0, 82.0),
2484 (65.0, 85.0),
2485 (50.0, 95.0),
2486 (35.0, 85.0),
2487 (23.0, 82.0),
2488 (20.0, 68.0),
2489 (5.0, 50.0),
2490 (20.0, 32.0),
2491 (23.0, 18.0),
2492 (35.0, 15.0),
2493 ])
2494 .with_quantity(13); let boundary = Boundary2D::rectangle(500.0, 500.0);
2498
2499 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2500 let nester = Nester2D::new(config);
2501
2502 let result = nester
2503 .solve(std::slice::from_ref(&gear_like), &boundary)
2504 .unwrap();
2505
2506 for (i, placement) in result.placements.iter().enumerate() {
2508 let origin_x = placement.position[0];
2509 let origin_y = placement.position[1];
2510 let rotation = placement.rotation.first().copied().unwrap_or(0.0);
2511
2512 let (g_min, g_max) = gear_like.aabb_at_rotation(rotation);
2513
2514 let actual_min_x = origin_x + g_min[0];
2515 let actual_max_x = origin_x + g_max[0];
2516 let actual_min_y = origin_y + g_min[1];
2517 let actual_max_y = origin_y + g_max[1];
2518
2519 assert!(
2520 actual_min_x >= -0.01,
2521 "Piece {}: Left edge {} should be >= 0",
2522 i,
2523 actual_min_x
2524 );
2525 assert!(
2526 actual_max_x <= 500.01,
2527 "Piece {}: Right edge {} should be <= 500",
2528 i,
2529 actual_max_x
2530 );
2531 assert!(
2532 actual_min_y >= -0.01,
2533 "Piece {}: Bottom edge {} should be >= 0",
2534 i,
2535 actual_min_y
2536 );
2537 assert!(
2538 actual_max_y <= 500.01,
2539 "Piece {}: Top edge {} should be <= 500",
2540 i,
2541 actual_max_y
2542 );
2543 }
2544 }
2545
2546 #[test]
2547 fn test_blf_bounds_trace() {
2548 let gear = Geometry2D::new("gear").with_polygon(vec![
2550 (50.0, 5.0),
2551 (65.0, 15.0),
2552 (77.0, 18.0),
2553 (80.0, 32.0),
2554 (95.0, 50.0),
2555 (80.0, 68.0),
2556 (77.0, 82.0),
2557 (65.0, 85.0),
2558 (50.0, 95.0),
2559 (35.0, 85.0),
2560 (23.0, 82.0),
2561 (20.0, 68.0),
2562 (5.0, 50.0),
2563 (20.0, 32.0),
2564 (23.0, 18.0),
2565 (35.0, 15.0),
2566 ]);
2567
2568 let (g_min, g_max) = gear.aabb();
2570 println!("Gear AABB: min={:?}, max={:?}", g_min, g_max);
2571 assert!(
2572 (g_min[0] - 5.0).abs() < 0.01,
2573 "g_min[0] should be 5, got {}",
2574 g_min[0]
2575 );
2576 assert!(
2577 (g_max[0] - 95.0).abs() < 0.01,
2578 "g_max[0] should be 95, got {}",
2579 g_max[0]
2580 );
2581
2582 let b_max_x = 500.0;
2584 let margin = 0.0;
2585 let max_valid_x = b_max_x - margin - g_max[0];
2586 println!(
2587 "max_valid_x = {} - {} - {} = {}",
2588 b_max_x, margin, g_max[0], max_valid_x
2589 );
2590 assert!(
2591 (max_valid_x - 405.0).abs() < 0.01,
2592 "max_valid_x should be 405, got {}",
2593 max_valid_x
2594 );
2595
2596 let boundary = Boundary2D::rectangle(500.0, 500.0);
2598 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2599 let nester = Nester2D::new(config);
2600
2601 let result = nester
2602 .solve(&[gear.clone().with_quantity(1)], &boundary)
2603 .unwrap();
2604
2605 assert_eq!(result.placements.len(), 1);
2606 let p = &result.placements[0];
2607 let origin_x = p.position[0];
2608 let rotation = p.rotation.first().copied().unwrap_or(0.0);
2609
2610 let (g_min_r, g_max_r) = gear.aabb_at_rotation(rotation);
2611 let actual_max_x = origin_x + g_max_r[0];
2612
2613 println!("Placement: origin_x={}, rotation={}", origin_x, rotation);
2614 println!(
2615 "At rotation {}: g_min={:?}, g_max={:?}",
2616 rotation, g_min_r, g_max_r
2617 );
2618 println!(
2619 "Actual max x: {} + {} = {}",
2620 origin_x, g_max_r[0], actual_max_x
2621 );
2622
2623 assert!(
2624 actual_max_x <= 500.01,
2625 "Geometry exceeds boundary: max_x={} > 500",
2626 actual_max_x
2627 );
2628 }
2629
2630 #[test]
2631 fn test_blf_bounds_many_pieces_direct() {
2632 let gear = Geometry2D::new("gear")
2634 .with_polygon(vec![
2635 (50.0, 5.0),
2636 (65.0, 15.0),
2637 (77.0, 18.0),
2638 (80.0, 32.0),
2639 (95.0, 50.0),
2640 (80.0, 68.0),
2641 (77.0, 82.0),
2642 (65.0, 85.0),
2643 (50.0, 95.0),
2644 (35.0, 85.0),
2645 (23.0, 82.0),
2646 (20.0, 68.0),
2647 (5.0, 50.0),
2648 (20.0, 32.0),
2649 (23.0, 18.0),
2650 (35.0, 15.0),
2651 ])
2652 .with_quantity(25); let boundary = Boundary2D::rectangle(500.0, 500.0);
2655 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2656 let nester = Nester2D::new(config);
2657
2658 let result = nester
2659 .solve(std::slice::from_ref(&gear), &boundary)
2660 .unwrap();
2661
2662 println!("Placed {} pieces", result.placements.len());
2663
2664 for (i, p) in result.placements.iter().enumerate() {
2666 let origin_x = p.position[0];
2667 let origin_y = p.position[1];
2668 let rotation = p.rotation.first().copied().unwrap_or(0.0);
2669
2670 let (g_min_r, g_max_r) = gear.aabb_at_rotation(rotation);
2671
2672 let actual_min_x = origin_x + g_min_r[0];
2673 let actual_max_x = origin_x + g_max_r[0];
2674 let actual_min_y = origin_y + g_min_r[1];
2675 let actual_max_y = origin_y + g_max_r[1];
2676
2677 println!(
2678 "Piece {}: origin=({:.1}, {:.1}), rot={:.2}, bounds=[{:.1},{:.1}]x[{:.1},{:.1}]",
2679 i,
2680 origin_x,
2681 origin_y,
2682 rotation,
2683 actual_min_x,
2684 actual_max_x,
2685 actual_min_y,
2686 actual_max_y
2687 );
2688
2689 assert!(
2690 actual_max_x <= 500.01,
2691 "Piece {}: Right edge {} > 500",
2692 i,
2693 actual_max_x
2694 );
2695 assert!(
2696 actual_max_y <= 500.01,
2697 "Piece {}: Top edge {} > 500",
2698 i,
2699 actual_max_y
2700 );
2701 }
2702 }
2703
2704 #[test]
2705 fn test_blf_bounds_multi_strip() {
2706 let gear = Geometry2D::new("gear")
2708 .with_polygon(vec![
2709 (50.0, 5.0),
2710 (65.0, 15.0),
2711 (77.0, 18.0),
2712 (80.0, 32.0),
2713 (95.0, 50.0),
2714 (80.0, 68.0),
2715 (77.0, 82.0),
2716 (65.0, 85.0),
2717 (50.0, 95.0),
2718 (35.0, 85.0),
2719 (23.0, 82.0),
2720 (20.0, 68.0),
2721 (5.0, 50.0),
2722 (20.0, 32.0),
2723 (23.0, 18.0),
2724 (35.0, 15.0),
2725 ])
2726 .with_quantity(50); let boundary = Boundary2D::rectangle(500.0, 500.0);
2729 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2730 let nester = Nester2D::new(config);
2731
2732 let result = nester
2734 .solve_multi_strip(std::slice::from_ref(&gear), &boundary)
2735 .unwrap();
2736
2737 println!(
2738 "Placed {} pieces across {} strips",
2739 result.placements.len(),
2740 result.boundaries_used
2741 );
2742
2743 let strip_width = 500.0;
2745 for (i, p) in result.placements.iter().enumerate() {
2746 let origin_x = p.position[0];
2747 let origin_y = p.position[1];
2748 let rotation = p.rotation.first().copied().unwrap_or(0.0);
2749 let strip_idx = p.boundary_index;
2750
2751 let local_x = origin_x - (strip_idx as f64 * strip_width);
2753
2754 let (_g_min_r, g_max_r) = gear.aabb_at_rotation(rotation);
2755
2756 let local_max_x = local_x + g_max_r[0];
2757 let local_max_y = origin_y + g_max_r[1];
2758
2759 println!(
2760 "Piece {}: strip={}, origin=({:.1}, {:.1}), local_x={:.1}, rot={:.2}, local_max_x={:.1}",
2761 i, strip_idx, origin_x, origin_y, local_x, rotation, local_max_x
2762 );
2763
2764 assert!(
2765 local_max_x <= 500.01,
2766 "Piece {}: In strip {}, local right edge {:.1} > 500",
2767 i,
2768 strip_idx,
2769 local_max_x
2770 );
2771 assert!(
2772 local_max_y <= 500.01,
2773 "Piece {}: Top edge {:.1} > 500",
2774 i,
2775 local_max_y
2776 );
2777 }
2778 }
2779
2780 #[test]
2781 fn test_blf_bounds_mixed_shapes() {
2782 let shapes = vec![
2784 Geometry2D::new("shape0")
2786 .with_polygon(vec![
2787 (0.0, 0.0),
2788 (180.0, 0.0),
2789 (195.0, 15.0),
2790 (200.0, 50.0),
2791 (200.0, 150.0),
2792 (195.0, 185.0),
2793 (180.0, 200.0),
2794 (20.0, 200.0),
2795 (5.0, 185.0),
2796 (0.0, 150.0),
2797 (0.0, 50.0),
2798 (5.0, 15.0),
2799 ])
2800 .with_quantity(2),
2801 Geometry2D::new("shape1")
2803 .with_polygon(vec![
2804 (60.0, 0.0),
2805 (85.0, 7.0),
2806 (104.0, 25.0),
2807 (118.0, 50.0),
2808 (120.0, 60.0),
2809 (118.0, 70.0),
2810 (104.0, 95.0),
2811 (85.0, 113.0),
2812 (60.0, 120.0),
2813 (35.0, 113.0),
2814 (16.0, 95.0),
2815 (2.0, 70.0),
2816 (0.0, 60.0),
2817 (2.0, 50.0),
2818 (16.0, 25.0),
2819 (35.0, 7.0),
2820 ])
2821 .with_quantity(4),
2822 Geometry2D::new("shape2")
2824 .with_polygon(vec![
2825 (0.0, 0.0),
2826 (80.0, 0.0),
2827 (80.0, 20.0),
2828 (20.0, 20.0),
2829 (20.0, 80.0),
2830 (0.0, 80.0),
2831 ])
2832 .with_quantity(6),
2833 Geometry2D::new("shape3")
2835 .with_polygon(vec![(0.0, 0.0), (70.0, 0.0), (0.0, 70.0)])
2836 .with_quantity(6),
2837 Geometry2D::new("shape4")
2839 .with_polygon(vec![(0.0, 0.0), (120.0, 0.0), (120.0, 60.0), (0.0, 60.0)])
2840 .with_quantity(4),
2841 Geometry2D::new("shape5")
2843 .with_polygon(vec![
2844 (15.0, 0.0),
2845 (45.0, 0.0),
2846 (60.0, 26.0),
2847 (45.0, 52.0),
2848 (15.0, 52.0),
2849 (0.0, 26.0),
2850 ])
2851 .with_quantity(8),
2852 Geometry2D::new("shape6")
2854 .with_polygon(vec![
2855 (0.0, 0.0),
2856 (90.0, 0.0),
2857 (90.0, 12.0),
2858 (55.0, 12.0),
2859 (55.0, 60.0),
2860 (35.0, 60.0),
2861 (35.0, 12.0),
2862 (0.0, 12.0),
2863 ])
2864 .with_quantity(4),
2865 Geometry2D::new("shape7")
2867 .with_polygon(vec![
2868 (0.0, 10.0),
2869 (10.0, 0.0),
2870 (70.0, 0.0),
2871 (80.0, 10.0),
2872 (80.0, 70.0),
2873 (70.0, 80.0),
2874 (10.0, 80.0),
2875 (0.0, 70.0),
2876 ])
2877 .with_quantity(3),
2878 Geometry2D::new("shape8_gear")
2880 .with_polygon(vec![
2881 (50.0, 5.0),
2882 (65.0, 15.0),
2883 (77.0, 18.0),
2884 (80.0, 32.0),
2885 (95.0, 50.0),
2886 (80.0, 68.0),
2887 (77.0, 82.0),
2888 (65.0, 85.0),
2889 (50.0, 95.0),
2890 (35.0, 85.0),
2891 (23.0, 82.0),
2892 (20.0, 68.0),
2893 (5.0, 50.0),
2894 (20.0, 32.0),
2895 (23.0, 18.0),
2896 (35.0, 15.0),
2897 ])
2898 .with_quantity(13),
2899 ];
2900
2901 let boundary = Boundary2D::rectangle(500.0, 500.0);
2903 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2904 let nester = Nester2D::new(config);
2905
2906 let result = nester.solve_multi_strip(&shapes, &boundary).unwrap();
2907
2908 println!(
2909 "Placed {} pieces across {} strips",
2910 result.placements.len(),
2911 result.boundaries_used
2912 );
2913
2914 let strip_width = 500.0;
2916 let gear_aabb = shapes[8].aabb();
2917 println!("Gear AABB: min={:?}, max={:?}", gear_aabb.0, gear_aabb.1);
2918
2919 let mut violations = Vec::new();
2920 for p in &result.placements {
2921 if p.geometry_id.as_str().starts_with("shape8") {
2922 let origin_x = p.position[0];
2923 let _origin_y = p.position[1];
2924 let rotation = p.rotation.first().copied().unwrap_or(0.0);
2925 let strip_idx = p.boundary_index;
2926 let local_x = origin_x - (strip_idx as f64 * strip_width);
2927
2928 let (_g_min_r, g_max_r) = shapes[8].aabb_at_rotation(rotation);
2929 let local_max_x = local_x + g_max_r[0];
2930
2931 println!(
2932 "{}: strip={}, local_x={:.1}, rot={:.2}, local_max_x={:.1}",
2933 p.geometry_id, strip_idx, local_x, rotation, local_max_x
2934 );
2935
2936 if local_max_x > 500.01 {
2937 violations.push((p.geometry_id.clone(), strip_idx, local_x, local_max_x));
2938 }
2939 }
2940 }
2941
2942 assert!(
2943 violations.is_empty(),
2944 "Found {} Gear pieces exceeding boundary: {:?}",
2945 violations.len(),
2946 violations
2947 );
2948 }
2949
2950 #[test]
2951 fn test_blf_bounds_expanded_like_benchmark() {
2952 type ShapeDef = (Vec<(f64, f64)>, usize, Vec<f64>);
2956 let shape_defs: Vec<ShapeDef> = vec![
2957 (
2958 vec![
2959 (0.0, 0.0),
2960 (180.0, 0.0),
2961 (195.0, 15.0),
2962 (200.0, 50.0),
2963 (200.0, 150.0),
2964 (195.0, 185.0),
2965 (180.0, 200.0),
2966 (20.0, 200.0),
2967 (5.0, 185.0),
2968 (0.0, 150.0),
2969 (0.0, 50.0),
2970 (5.0, 15.0),
2971 ],
2972 2,
2973 vec![0.0, 90.0, 180.0, 270.0],
2974 ),
2975 (
2976 vec![
2977 (60.0, 0.0),
2978 (85.0, 7.0),
2979 (104.0, 25.0),
2980 (118.0, 50.0),
2981 (120.0, 60.0),
2982 (118.0, 70.0),
2983 (104.0, 95.0),
2984 (85.0, 113.0),
2985 (60.0, 120.0),
2986 (35.0, 113.0),
2987 (16.0, 95.0),
2988 (2.0, 70.0),
2989 (0.0, 60.0),
2990 (2.0, 50.0),
2991 (16.0, 25.0),
2992 (35.0, 7.0),
2993 ],
2994 4,
2995 vec![0.0, 45.0, 90.0, 135.0],
2996 ),
2997 (
2998 vec![
2999 (0.0, 0.0),
3000 (80.0, 0.0),
3001 (80.0, 20.0),
3002 (20.0, 20.0),
3003 (20.0, 80.0),
3004 (0.0, 80.0),
3005 ],
3006 6,
3007 vec![0.0, 90.0, 180.0, 270.0],
3008 ),
3009 (
3010 vec![(0.0, 0.0), (70.0, 0.0), (0.0, 70.0)],
3011 6,
3012 vec![0.0, 90.0, 180.0, 270.0],
3013 ),
3014 (
3015 vec![(0.0, 0.0), (120.0, 0.0), (120.0, 60.0), (0.0, 60.0)],
3016 4,
3017 vec![0.0, 90.0],
3018 ),
3019 (
3020 vec![
3021 (15.0, 0.0),
3022 (45.0, 0.0),
3023 (60.0, 26.0),
3024 (45.0, 52.0),
3025 (15.0, 52.0),
3026 (0.0, 26.0),
3027 ],
3028 8,
3029 vec![0.0, 60.0, 120.0],
3030 ),
3031 (
3032 vec![
3033 (0.0, 0.0),
3034 (90.0, 0.0),
3035 (90.0, 12.0),
3036 (55.0, 12.0),
3037 (55.0, 60.0),
3038 (35.0, 60.0),
3039 (35.0, 12.0),
3040 (0.0, 12.0),
3041 ],
3042 4,
3043 vec![0.0, 90.0, 180.0, 270.0],
3044 ),
3045 (
3046 vec![
3047 (0.0, 10.0),
3048 (10.0, 0.0),
3049 (70.0, 0.0),
3050 (80.0, 10.0),
3051 (80.0, 70.0),
3052 (70.0, 80.0),
3053 (10.0, 80.0),
3054 (0.0, 70.0),
3055 ],
3056 3,
3057 vec![0.0, 90.0],
3058 ),
3059 (
3061 vec![
3062 (50.0, 5.0),
3063 (65.0, 15.0),
3064 (77.0, 18.0),
3065 (80.0, 32.0),
3066 (95.0, 50.0),
3067 (80.0, 68.0),
3068 (77.0, 82.0),
3069 (65.0, 85.0),
3070 (50.0, 95.0),
3071 (35.0, 85.0),
3072 (23.0, 82.0),
3073 (20.0, 68.0),
3074 (5.0, 50.0),
3075 (20.0, 32.0),
3076 (23.0, 18.0),
3077 (35.0, 15.0),
3078 ],
3079 13,
3080 vec![0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0],
3081 ),
3082 ];
3083
3084 let mut geometries = Vec::new();
3086 let mut piece_id = 0;
3087 for (vertices, demand, rotations) in shape_defs.iter() {
3088 for _ in 0..*demand {
3089 let geom = Geometry2D::new(format!("piece_{}", piece_id))
3090 .with_polygon(vertices.clone())
3091 .with_rotations_deg(rotations.clone());
3092 geometries.push(geom);
3093 piece_id += 1;
3094 }
3095 }
3096
3097 let gear_geom = Geometry2D::new("gear_check").with_polygon(shape_defs[8].0.clone());
3099 let (gear_min, gear_max) = gear_geom.aabb();
3100 println!("Gear AABB: min={:?}, max={:?}", gear_min, gear_max);
3101
3102 let boundary = Boundary2D::rectangle(500.0, 500.0);
3103 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
3104 let nester = Nester2D::new(config);
3105
3106 let result = nester.solve_multi_strip(&geometries, &boundary).unwrap();
3107
3108 println!(
3109 "Placed {} pieces across {} strips",
3110 result.placements.len(),
3111 result.boundaries_used
3112 );
3113
3114 let strip_width = 500.0;
3116 let mut violations = Vec::new();
3117
3118 for p in &result.placements {
3119 let id_num: usize = p
3120 .geometry_id
3121 .as_str()
3122 .strip_prefix("piece_")
3123 .and_then(|s| s.parse().ok())
3124 .unwrap_or(0);
3125
3126 if (37..=49).contains(&id_num) {
3128 let origin_x = p.position[0];
3129 let rotation = p.rotation.first().copied().unwrap_or(0.0);
3130 let strip_idx = p.boundary_index;
3131 let local_x = origin_x - (strip_idx as f64 * strip_width);
3132
3133 let (_, g_max_r) = gear_geom.aabb_at_rotation(rotation);
3134 let local_max_x = local_x + g_max_r[0];
3135
3136 println!(
3137 "{}: strip={}, local_x={:.1}, rot={:.2}, local_max_x={:.1}",
3138 p.geometry_id, strip_idx, local_x, rotation, local_max_x
3139 );
3140
3141 if local_max_x > 500.01 {
3142 violations.push((p.geometry_id.clone(), strip_idx, local_x, local_max_x));
3143 }
3144 }
3145 }
3146
3147 assert!(
3148 violations.is_empty(),
3149 "Found {} Gear pieces exceeding boundary: {:?}",
3150 violations.len(),
3151 violations
3152 );
3153 }
3154
3155 fn aabbs_overlap(
3157 a_min: [f64; 2],
3158 a_max: [f64; 2],
3159 b_min: [f64; 2],
3160 b_max: [f64; 2],
3161 tolerance: f64,
3162 ) -> bool {
3163 let x_overlap = a_min[0] < b_max[0] - tolerance && a_max[0] > b_min[0] + tolerance;
3165 let y_overlap = a_min[1] < b_max[1] - tolerance && a_max[1] > b_min[1] + tolerance;
3166 x_overlap && y_overlap
3167 }
3168
3169 #[test]
3171 fn test_all_strategies_boundary_and_overlap() {
3172 use std::collections::HashMap;
3173
3174 let shapes = vec![
3176 Geometry2D::new("shape0")
3177 .with_polygon(vec![
3178 (0.0, 0.0),
3179 (180.0, 0.0),
3180 (195.0, 15.0),
3181 (200.0, 50.0),
3182 (200.0, 150.0),
3183 (195.0, 185.0),
3184 (180.0, 200.0),
3185 (20.0, 200.0),
3186 (5.0, 185.0),
3187 (0.0, 150.0),
3188 (0.0, 50.0),
3189 (5.0, 15.0),
3190 ])
3191 .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
3192 .with_quantity(2),
3193 Geometry2D::new("shape1_flange")
3194 .with_polygon(vec![
3195 (60.0, 0.0),
3196 (85.0, 7.0),
3197 (104.0, 25.0),
3198 (118.0, 50.0),
3199 (120.0, 60.0),
3200 (118.0, 70.0),
3201 (104.0, 95.0),
3202 (85.0, 113.0),
3203 (60.0, 120.0),
3204 (35.0, 113.0),
3205 (16.0, 95.0),
3206 (2.0, 70.0),
3207 (0.0, 60.0),
3208 (2.0, 50.0),
3209 (16.0, 25.0),
3210 (35.0, 7.0),
3211 ])
3212 .with_rotations_deg(vec![0.0, 45.0, 90.0, 135.0])
3213 .with_quantity(4),
3214 Geometry2D::new("shape2_lbracket")
3215 .with_polygon(vec![
3216 (0.0, 0.0),
3217 (80.0, 0.0),
3218 (80.0, 20.0),
3219 (20.0, 20.0),
3220 (20.0, 80.0),
3221 (0.0, 80.0),
3222 ])
3223 .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
3224 .with_quantity(6),
3225 Geometry2D::new("shape3_triangle")
3226 .with_polygon(vec![(0.0, 0.0), (70.0, 0.0), (0.0, 70.0)])
3227 .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
3228 .with_quantity(6),
3229 Geometry2D::new("shape4_rect")
3230 .with_polygon(vec![(0.0, 0.0), (120.0, 0.0), (120.0, 60.0), (0.0, 60.0)])
3231 .with_rotations_deg(vec![0.0, 90.0])
3232 .with_quantity(4),
3233 Geometry2D::new("shape5_hexagon")
3234 .with_polygon(vec![
3235 (15.0, 0.0),
3236 (45.0, 0.0),
3237 (60.0, 26.0),
3238 (45.0, 52.0),
3239 (15.0, 52.0),
3240 (0.0, 26.0),
3241 ])
3242 .with_rotations_deg(vec![0.0, 60.0, 120.0])
3243 .with_quantity(8),
3244 Geometry2D::new("shape6_tstiff")
3245 .with_polygon(vec![
3246 (0.0, 0.0),
3247 (90.0, 0.0),
3248 (90.0, 12.0),
3249 (55.0, 12.0),
3250 (55.0, 60.0),
3251 (35.0, 60.0),
3252 (35.0, 12.0),
3253 (0.0, 12.0),
3254 ])
3255 .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
3256 .with_quantity(4),
3257 Geometry2D::new("shape7_mount")
3258 .with_polygon(vec![
3259 (0.0, 10.0),
3260 (10.0, 0.0),
3261 (70.0, 0.0),
3262 (80.0, 10.0),
3263 (80.0, 70.0),
3264 (70.0, 80.0),
3265 (10.0, 80.0),
3266 (0.0, 70.0),
3267 ])
3268 .with_rotations_deg(vec![0.0, 90.0])
3269 .with_quantity(3),
3270 Geometry2D::new("shape8_gear")
3271 .with_polygon(vec![
3272 (50.0, 5.0),
3273 (65.0, 15.0),
3274 (77.0, 18.0),
3275 (80.0, 32.0),
3276 (95.0, 50.0),
3277 (80.0, 68.0),
3278 (77.0, 82.0),
3279 (65.0, 85.0),
3280 (50.0, 95.0),
3281 (35.0, 85.0),
3282 (23.0, 82.0),
3283 (20.0, 68.0),
3284 (5.0, 50.0),
3285 (20.0, 32.0),
3286 (23.0, 18.0),
3287 (35.0, 15.0),
3288 ])
3289 .with_rotations_deg(vec![0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0])
3290 .with_quantity(13),
3291 ];
3292
3293 let geom_map: HashMap<String, &Geometry2D> =
3295 shapes.iter().map(|g| (g.id().clone(), g)).collect();
3296
3297 let boundary = Boundary2D::rectangle(500.0, 500.0);
3298 let strip_width = 500.0;
3299
3300 let strategies = vec![
3302 Strategy::BottomLeftFill,
3303 Strategy::NfpGuided,
3304 Strategy::GeneticAlgorithm,
3305 Strategy::Brkga,
3306 Strategy::SimulatedAnnealing,
3307 Strategy::Gdrr,
3308 Strategy::Alns,
3309 ];
3310
3311 for strategy in strategies {
3312 println!("\n========== Testing {:?} ==========", strategy);
3313
3314 let config = Config::default()
3315 .with_strategy(strategy)
3316 .with_time_limit(30000); let nester = Nester2D::new(config);
3318
3319 let result = match nester.solve_multi_strip(&shapes, &boundary) {
3320 Ok(r) => r,
3321 Err(e) => {
3322 println!(" Strategy {:?} failed: {}", strategy, e);
3323 continue;
3324 }
3325 };
3326
3327 println!(
3328 " Placed {} pieces across {} strips",
3329 result.placements.len(),
3330 result.boundaries_used
3331 );
3332
3333 let mut boundary_violations = Vec::new();
3335 for p in &result.placements {
3336 let base_id = p.geometry_id.split('_').next().unwrap_or(&p.geometry_id);
3338 let full_id = if base_id.starts_with("shape") {
3339 shapes
3341 .iter()
3342 .find(|g| p.geometry_id.starts_with(g.id()))
3343 .map(|g| g.id().as_str())
3344 } else {
3345 geom_map.get(&p.geometry_id).map(|g| g.id().as_str())
3346 };
3347
3348 let geom = match full_id.and_then(|id| geom_map.get(id)) {
3349 Some(g) => *g,
3350 None => {
3351 match shapes.iter().find(|g| p.geometry_id.starts_with(g.id())) {
3353 Some(g) => g,
3354 None => {
3355 println!(
3356 " WARNING: Could not find geometry for {}",
3357 p.geometry_id
3358 );
3359 continue;
3360 }
3361 }
3362 }
3363 };
3364
3365 let origin_x = p.position[0];
3366 let origin_y = p.position[1];
3367 let rotation = p.rotation.first().copied().unwrap_or(0.0);
3368 let strip_idx = p.boundary_index;
3369
3370 let local_x = origin_x - (strip_idx as f64 * strip_width);
3372
3373 let (g_min, g_max) = geom.aabb_at_rotation(rotation);
3374
3375 let local_min_x = local_x + g_min[0];
3377 let local_max_x = local_x + g_max[0];
3378 let local_min_y = origin_y + g_min[1];
3379 let local_max_y = origin_y + g_max[1];
3380
3381 let tolerance = 0.1;
3383 if local_min_x < -tolerance
3384 || local_max_x > 500.0 + tolerance
3385 || local_min_y < -tolerance
3386 || local_max_y > 500.0 + tolerance
3387 {
3388 boundary_violations.push(format!(
3389 "{} in strip {}: bounds ({:.1}, {:.1}) to ({:.1}, {:.1})",
3390 p.geometry_id,
3391 strip_idx,
3392 local_min_x,
3393 local_min_y,
3394 local_max_x,
3395 local_max_y
3396 ));
3397 }
3398 }
3399
3400 if !boundary_violations.is_empty() {
3401 println!(" BOUNDARY VIOLATIONS ({}):", boundary_violations.len());
3402 for v in &boundary_violations {
3403 println!(" - {}", v);
3404 }
3405 }
3406
3407 let mut overlaps = Vec::new();
3409 let placements: Vec<_> = result.placements.iter().collect();
3410
3411 for i in 0..placements.len() {
3412 for j in (i + 1)..placements.len() {
3413 let p1 = placements[i];
3414 let p2 = placements[j];
3415
3416 if p1.boundary_index != p2.boundary_index {
3418 continue;
3419 }
3420
3421 let g1 = shapes.iter().find(|g| p1.geometry_id.starts_with(g.id()));
3423 let g2 = shapes.iter().find(|g| p2.geometry_id.starts_with(g.id()));
3424
3425 let (g1, g2) = match (g1, g2) {
3426 (Some(a), Some(b)) => (a, b),
3427 _ => continue,
3428 };
3429
3430 let strip_idx = p1.boundary_index;
3431 let local_x1 = p1.position[0] - (strip_idx as f64 * strip_width);
3432 let local_x2 = p2.position[0] - (strip_idx as f64 * strip_width);
3433
3434 let rot1 = p1.rotation.first().copied().unwrap_or(0.0);
3435 let rot2 = p2.rotation.first().copied().unwrap_or(0.0);
3436
3437 let (g1_min, g1_max) = g1.aabb_at_rotation(rot1);
3438 let (g2_min, g2_max) = g2.aabb_at_rotation(rot2);
3439
3440 let a_min = [local_x1 + g1_min[0], p1.position[1] + g1_min[1]];
3441 let a_max = [local_x1 + g1_max[0], p1.position[1] + g1_max[1]];
3442 let b_min = [local_x2 + g2_min[0], p2.position[1] + g2_min[1]];
3443 let b_max = [local_x2 + g2_max[0], p2.position[1] + g2_max[1]];
3444
3445 if aabbs_overlap(a_min, a_max, b_min, b_max, 1.0) {
3446 overlaps.push(format!(
3447 "{} and {} in strip {}",
3448 p1.geometry_id, p2.geometry_id, strip_idx
3449 ));
3450 }
3451 }
3452 }
3453
3454 if !overlaps.is_empty() {
3455 println!(" OVERLAPS ({}):", overlaps.len());
3456 for o in overlaps.iter().take(10) {
3457 println!(" - {}", o);
3458 }
3459 if overlaps.len() > 10 {
3460 println!(" ... and {} more", overlaps.len() - 10);
3461 }
3462 }
3463
3464 assert!(
3466 boundary_violations.is_empty(),
3467 "{:?}: Found {} boundary violations",
3468 strategy,
3469 boundary_violations.len()
3470 );
3471
3472 println!(" ✓ All placements within boundary");
3473 println!(" ✓ No AABB overlaps detected");
3474 }
3475 }
3476
3477 #[test]
3485 fn test_multi_strip_distributes_all_instances() {
3486 let geometries = vec![Geometry2D::rectangle("part", 100.0, 100.0).with_quantity(20)];
3487 let boundary = Boundary2D::rectangle(300.0, 300.0);
3488 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
3489 let nester = Nester2D::new(config);
3490
3491 let result = nester.solve_multi_strip(&geometries, &boundary).unwrap();
3492
3493 assert_eq!(
3495 result.placements.len(),
3496 20,
3497 "all 20 instances must be placed"
3498 );
3499 assert_eq!(
3500 result.boundaries_used, 3,
3501 "20 of 100x100 in 300x300 => 3 sheets"
3502 );
3503 assert!(
3504 result.unplaced.is_empty(),
3505 "nothing should be unplaced, got {:?}",
3506 result.unplaced
3507 );
3508 assert_eq!(result.total_requested, 20);
3510
3511 let mut seen = std::collections::HashSet::new();
3514 for p in &result.placements {
3515 assert!(
3516 seen.insert((p.geometry_id.clone(), p.instance)),
3517 "duplicate (id, instance) = ({}, {}) across sheets",
3518 p.geometry_id,
3519 p.instance
3520 );
3521 }
3522 let mut sheets: Vec<usize> = result.placements.iter().map(|p| p.boundary_index).collect();
3524 sheets.sort_unstable();
3525 sheets.dedup();
3526 assert_eq!(sheets, vec![0, 1, 2]);
3527 }
3528
3529 #[test]
3532 fn test_multi_strip_oversized_reported_unplaced() {
3533 let geometries = vec![
3534 Geometry2D::rectangle("ok", 50.0, 50.0).with_quantity(2),
3535 Geometry2D::rectangle("toobig", 400.0, 400.0).with_quantity(3),
3536 ];
3537 let boundary = Boundary2D::rectangle(300.0, 300.0);
3538 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
3539 let nester = Nester2D::new(config);
3540
3541 let result = nester.solve_multi_strip(&geometries, &boundary).unwrap();
3542
3543 assert_eq!(result.total_requested, 5, "2 + 3 instances requested");
3544 assert_eq!(result.placements.len(), 2);
3546 assert!(
3547 result.unplaced.contains(&"toobig".to_string()),
3548 "oversized geometry must surface in unplaced, got {:?}",
3549 result.unplaced
3550 );
3551 }
3552}