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, run_ga_nesting_with_progress};
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::{Config, ProgressCallback, ProgressInfo, Solver, Strategy};
27use u_nesting_core::{Error, Placement, Result, SolveResult};
28
29use crate::placement_utils::{hole_nfps, inset_boundary, offset_nfp};
30use std::sync::atomic::{AtomicBool, Ordering};
31use std::sync::Arc;
32use u_nesting_core::timing::Timer;
33
34fn mirror_candidates(geom: &Geometry2D) -> &'static [bool] {
46 if geom.allow_flip() {
47 &[false, true]
48 } else {
49 &[false]
50 }
51}
52
53fn solution_quality(
67 result: &SolveResult<f64>,
68 geometries: &[Geometry2D],
69 boundary: &Boundary2D,
70) -> (usize, f64) {
71 use std::collections::HashMap;
72 let geom_map: HashMap<_, _> = geometries.iter().map(|g| (g.id().clone(), g)).collect();
73
74 let mut min_x = f64::INFINITY;
75 let mut min_y = f64::INFINITY;
76 let mut max_x = f64::NEG_INFINITY;
77 let mut max_y = f64::NEG_INFINITY;
78
79 for p in &result.placements {
80 if let Some(geom) = geom_map.get(&p.geometry_id) {
81 let x = p.position.first().copied().unwrap_or(0.0);
82 let y = p.position.get(1).copied().unwrap_or(0.0);
83 let rot = p.rotation.first().copied().unwrap_or(0.0);
84 let (g_min, g_max) = geom.aabb_at_rotation(rot);
85 min_x = min_x.min(x + g_min[0]);
86 min_y = min_y.min(y + g_min[1]);
87 max_x = max_x.max(x + g_max[0]);
88 max_y = max_y.max(y + g_max[1]);
89 }
90 }
91
92 if result.placements.is_empty() {
93 return (0, f64::INFINITY);
94 }
95
96 let strip_length = match PackingAxis::of(boundary) {
98 PackingAxis::Y => max_y - min_y,
99 PackingAxis::X => max_x - min_x,
100 };
101 (result.placements.len(), strip_length)
102}
103
104pub struct Nester2D {
106 config: Config,
107 cancelled: Arc<AtomicBool>,
108 #[allow(dead_code)] nfp_cache: NfpCache,
110}
111
112#[cfg(feature = "milp")]
123fn milp_grid_step(geometries: &[Geometry2D], boundary: &Boundary2D) -> f64 {
124 let smallest_side = geometries
125 .iter()
126 .map(|g| {
127 let (g_min, g_max) = g.aabb_at_rotation(0.0);
128 (g_max[0] - g_min[0]).min(g_max[1] - g_min[1])
129 })
130 .fold(f64::INFINITY, f64::min);
131 if !smallest_side.is_finite() || smallest_side <= 0.0 {
132 return 1.0;
133 }
134 let (b_min, b_max) = boundary.aabb();
135 let longest_side = (b_max[0] - b_min[0]).max(b_max[1] - b_min[1]);
136 (smallest_side / 4.0).max(1.0).max(longest_side / 256.0)
139}
140
141#[cfg(feature = "milp")]
147fn milp_time_limit(requested_ms: u64) -> u64 {
148 if requested_ms == 0 {
149 u64::MAX / 2
150 } else {
151 requested_ms
152 }
153}
154
155#[derive(Debug, Clone, Copy)]
158struct BlfCandidate {
159 rotation: f64,
160 extent_across: f64,
161 extent_along: f64,
162 place_across: f64,
163 place_along: f64,
164 opened_row: bool,
166}
167
168impl Nester2D {
169 fn run_strategy(
173 &self,
174 geometries: &[Geometry2D],
175 boundary: &Boundary2D,
176 ) -> Result<SolveResult<f64>> {
177 match self.config.strategy {
178 Strategy::BottomLeftFill => self.bottom_left_fill(geometries, boundary),
179 Strategy::NfpGuided => self.nfp_guided_blf(geometries, boundary),
180 Strategy::GeneticAlgorithm => self.genetic_algorithm(geometries, boundary),
181 Strategy::Brkga => self.brkga(geometries, boundary),
182 Strategy::SimulatedAnnealing => self.simulated_annealing(geometries, boundary),
183 Strategy::Gdrr => self.gdrr(geometries, boundary),
184 Strategy::Alns => self.alns(geometries, boundary),
185 #[cfg(feature = "milp")]
186 Strategy::MilpExact => self.milp_exact(geometries, boundary),
187 #[cfg(feature = "milp")]
188 Strategy::HybridExact => self.hybrid_exact(geometries, boundary),
189 Strategy::ExtremePoint => Err(Error::invalid_option(
190 "strategy",
191 "strategy ExtremePoint is 3D only; 2D nesting offers BottomLeftFill, \
192 NfpGuided, GeneticAlgorithm, Brkga, SimulatedAnnealing, Gdrr and Alns"
193 .into(),
194 )),
195 #[cfg(not(feature = "milp"))]
196 Strategy::MilpExact | Strategy::HybridExact => Err(Error::invalid_option(
197 "strategy",
198 format!(
199 "strategy {:?} needs the `milp` feature, which this build does not have",
200 self.config.strategy
201 ),
202 )),
203 }
204 }
205
206 pub fn new(config: Config) -> Self {
208 Self {
209 config,
210 cancelled: Arc::new(AtomicBool::new(false)),
211 nfp_cache: NfpCache::new(),
212 }
213 }
214
215 pub fn default_config() -> Self {
217 Self::new(Config::default())
218 }
219
220 fn bottom_left_fill(
230 &self,
231 geometries: &[Geometry2D],
232 boundary: &Boundary2D,
233 ) -> Result<SolveResult<f64>> {
234 self.bottom_left_fill_impl(geometries, boundary, None)
235 }
236
237 fn nfp_guided_blf(
242 &self,
243 geometries: &[Geometry2D],
244 boundary: &Boundary2D,
245 ) -> Result<SolveResult<f64>> {
246 let start = Timer::now();
247 let mut result = SolveResult::new();
248 let mut placements = Vec::new();
249 let mut placed_geometries: Vec<PlacedGeometry> = Vec::new();
250
251 let margin = self.config.margin;
252 let spacing = self.config.spacing;
253
254 let boundary_polygon = inset_boundary(boundary, margin);
256
257 let mut total_placed_area = 0.0;
258
259 let sample_step = self.compute_sample_step(geometries);
261
262 for geom in geometries {
263 geom.validate()?;
264
265 let rotation_angles: Vec<f64> = geom.rotations();
268
269 let mirror_candidates = mirror_candidates(geom);
270
271 for instance in 0..geom.quantity() {
272 if self.cancelled.load(Ordering::Relaxed) {
273 result.computation_time_ms = start.elapsed_ms();
274 return Ok(result);
275 }
276
277 if self.config.time_limit_ms > 0 && start.elapsed_ms() >= self.config.time_limit_ms
279 {
280 result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
281 result.utilization = total_placed_area / boundary.measure();
282 result.computation_time_ms = start.elapsed_ms();
283 result.placements = placements;
284 return Ok(result);
285 }
286
287 let mut best_placement: Option<(f64, f64, f64, bool)> = None; for &rotation in &rotation_angles {
291 for &mirror in mirror_candidates {
292 let ifp = match compute_ifp_with_margin_and_mirror(
294 &boundary_polygon,
295 geom,
296 rotation,
297 0.0,
299 mirror,
300 ) {
301 Ok(ifp) => ifp,
302 Err(_) => continue,
303 };
304
305 if ifp.is_empty() {
306 continue;
307 }
308
309 let mut nfps: Vec<Nfp> = Vec::new();
311 for placed in &placed_geometries {
312 let cache_key = (
315 placed.geometry.id().as_str(),
316 geom.id().as_str(),
317 rotation - placed.rotation, placed.mirrored,
319 mirror,
320 );
321
322 let nfp_at_origin =
327 match self.nfp_cache.get_or_compute_mirrored(cache_key, || {
328 let placed_at_origin = placed.geometry.clone();
329 compute_nfp_mirrored(
330 &placed_at_origin,
331 geom,
332 rotation - placed.rotation,
333 placed.mirrored,
334 mirror,
335 )
336 .map(|nfp| offset_nfp(&nfp, spacing))
340 }) {
341 Ok(nfp) => nfp,
342 Err(_) => continue,
343 };
344
345 let rotated_nfp = rotate_nfp(&nfp_at_origin, placed.rotation);
348 let translated_nfp = translate_nfp(&rotated_nfp, placed.position);
349 nfps.push(translated_nfp);
350 }
351
352 nfps.extend(hole_nfps(boundary, geom, rotation, mirror, margin));
357 let nfp_refs: Vec<&Nfp> = nfps.iter().collect();
358 if let Some((x, y)) = find_bottom_left_placement(
359 &ifp,
360 &nfp_refs,
361 sample_step,
362 PackingAxis::of(boundary),
363 ) {
364 let is_better =
366 match best_placement {
367 None => true,
368 Some((best_x, best_y, _, _)) => PackingAxis::of(boundary)
369 .precedes((x, y), (best_x, best_y), 1e-6),
370 };
371 if is_better {
372 best_placement = Some((x, y, rotation, mirror));
373 }
374 }
375 }
376 }
377
378 if let Some((x, y, rotation, mirror)) = best_placement {
380 let geom_aabb = geom.aabb_at_rotation_mirrored(rotation, mirror);
387 let boundary_aabb = boundary.aabb();
388
389 if let Some((clamped_x, clamped_y)) = clamp_placement_to_boundary_with_margin(
390 x,
391 y,
392 geom_aabb,
393 boundary_aabb,
394 margin,
395 ) {
396 let placement = Placement::new_2d(
397 geom.id().clone(),
398 instance,
399 clamped_x,
400 clamped_y,
401 rotation,
402 )
403 .with_mirrored(mirror);
404
405 placements.push(placement);
406 placed_geometries.push(
407 PlacedGeometry::new(geom.clone(), (clamped_x, clamped_y), rotation)
408 .with_mirrored(mirror),
409 );
410 total_placed_area += geom.measure();
411 } else {
412 result.unplaced.push(geom.id().clone());
414 }
415 } else {
416 result.unplaced.push(geom.id().clone());
418 }
419 }
420 }
421
422 result.placements = placements;
423 result.boundaries_used = 1;
424 result.utilization = total_placed_area / boundary.measure();
425 result.computation_time_ms = start.elapsed_ms();
426
427 Ok(result)
428 }
429
430 fn compute_sample_step(&self, geometries: &[Geometry2D]) -> f64 {
432 if geometries.is_empty() {
433 return 1.0;
434 }
435
436 let mut min_dim = f64::INFINITY;
438 for geom in geometries {
439 let (g_min, g_max) = geom.aabb();
440 let width = g_max[0] - g_min[0];
441 let height = g_max[1] - g_min[1];
442 min_dim = min_dim.min(width).min(height);
443 }
444
445 (min_dim / 4.0).clamp(0.5, 10.0)
447 }
448
449 fn not_worse_than_baselines(
459 &self,
460 meta: SolveResult<f64>,
461 geometries: &[Geometry2D],
462 boundary: &Boundary2D,
463 greedy: Option<SolveResult<f64>>,
464 ) -> SolveResult<f64> {
465 let baselines = self
466 .bottom_left_fill(geometries, boundary)
467 .ok()
468 .into_iter()
469 .chain(greedy);
470 let quality = |r: &SolveResult<f64>| solution_quality(r, geometries, boundary);
471 let beats =
474 |a: (usize, f64), b: (usize, f64)| a.0 > b.0 || (a.0 == b.0 && a.1 < b.1 - 1e-6);
475 let mut best: Option<SolveResult<f64>> = None;
476 for baseline in baselines {
477 let current = best.as_ref().map_or(quality(&meta), quality);
478 if beats(quality(&baseline), current) {
479 best = Some(baseline);
480 }
481 }
482 match best {
483 Some(mut floored) => {
489 floored.strategy = meta.strategy;
490 floored.generations = meta.generations;
491 floored.best_fitness = meta.best_fitness;
492 floored.fitness_history = meta.fitness_history;
493 floored.target_reached = meta.target_reached;
494 floored
495 }
496 None => meta,
497 }
498 }
499
500 fn search_budget_ms(&self, default_ms: u64) -> u64 {
504 if self.config.time_limit_ms > 0 {
505 (self.config.time_limit_ms / 4)
506 .max(5000)
507 .min(self.config.time_limit_ms)
508 } else {
509 default_ms
510 }
511 }
512
513 fn greedy_start(
520 &self,
521 geometries: &[Geometry2D],
522 boundary: &Boundary2D,
523 budget_ms: u64,
524 ) -> (Option<SolveResult<f64>>, u64) {
525 let started = Timer::now();
526 let greedy = Nester2D {
527 config: Config {
528 time_limit_ms: budget_ms,
529 ..self.config.clone()
530 },
531 cancelled: self.cancelled.clone(),
532 nfp_cache: NfpCache::new(),
533 }
534 .nfp_guided_blf(geometries, boundary)
535 .ok();
536 (
537 greedy,
538 budget_ms.saturating_sub(started.elapsed_ms()).max(1),
539 )
540 }
541
542 fn genetic_algorithm(
547 &self,
548 geometries: &[Geometry2D],
549 boundary: &Boundary2D,
550 ) -> Result<SolveResult<f64>> {
551 let (time_limit_ms, greedy) = {
552 let budget = self.search_budget_ms(15000);
553 let (greedy, remaining) = self.greedy_start(geometries, boundary, budget);
554 (remaining, greedy)
555 };
556
557 let ga_config = GaConfig::default()
558 .with_population_size(self.config.population_size.min(30)) .with_max_generations(self.config.max_generations.min(50)) .with_crossover_rate(self.config.crossover_rate)
561 .with_mutation_rate(self.config.mutation_rate)
562 .with_time_limit(std::time::Duration::from_millis(time_limit_ms));
563
564 let result = run_ga_nesting(
565 geometries,
566 boundary,
567 &self.config,
568 ga_config,
569 self.cancelled.clone(),
570 greedy.as_ref().map(|g| g.placements.as_slice()),
571 )?;
572
573 Ok(self.not_worse_than_baselines(result, geometries, boundary, greedy))
574 }
575
576 fn brkga(&self, geometries: &[Geometry2D], boundary: &Boundary2D) -> Result<SolveResult<f64>> {
580 let (time_limit_ms, greedy) = {
581 let budget = self.search_budget_ms(15000);
582 let (greedy, remaining) = self.greedy_start(geometries, boundary, budget);
583 (remaining, greedy)
584 };
585
586 let brkga_config = BrkgaConfig::default()
587 .with_population_size(self.config.population_size.min(30))
591 .with_max_generations(50) .with_elite_fraction(0.2)
593 .with_mutant_fraction(0.15)
594 .with_elite_bias(0.7)
595 .with_time_limit(std::time::Duration::from_millis(time_limit_ms));
596
597 let result = run_brkga_nesting(
598 geometries,
599 boundary,
600 &self.config,
601 brkga_config,
602 self.cancelled.clone(),
603 greedy.as_ref().map(|g| g.placements.as_slice()),
604 )?;
605
606 Ok(self.not_worse_than_baselines(result, geometries, boundary, greedy))
607 }
608
609 fn simulated_annealing(
614 &self,
615 geometries: &[Geometry2D],
616 boundary: &Boundary2D,
617 ) -> Result<SolveResult<f64>> {
618 let (time_limit_ms, greedy) = {
619 let budget = self.search_budget_ms(10000);
620 let (greedy, remaining) = self.greedy_start(geometries, boundary, budget);
621 (remaining, greedy)
622 };
623
624 let sa_config = SaConfig::default()
625 .with_initial_temp(50.0) .with_final_temp(1.0) .with_cooling_rate(0.9) .with_iterations_per_temp(20) .with_max_iterations(500) .with_time_limit(std::time::Duration::from_millis(time_limit_ms));
631
632 let result = run_sa_nesting(
633 geometries,
634 boundary,
635 &self.config,
636 sa_config,
637 self.cancelled.clone(),
638 greedy.as_ref().map(|g| g.placements.as_slice()),
639 )?;
640
641 Ok(self.not_worse_than_baselines(result, geometries, boundary, greedy))
642 }
643
644 fn gdrr(&self, geometries: &[Geometry2D], boundary: &Boundary2D) -> Result<SolveResult<f64>> {
646 let (time_limit, greedy) = {
647 let budget = self.search_budget_ms(10000);
648 let (greedy, remaining) = self.greedy_start(geometries, boundary, budget);
649 (remaining, greedy)
650 };
651 let gdrr_config = GdrrConfig::default()
652 .with_max_iterations(1000) .with_time_limit_ms(time_limit)
654 .with_ruin_ratio(0.1, 0.3) .with_lahc_list_length(30); let result = run_gdrr_nesting(
658 geometries,
659 boundary,
660 &self.config,
661 &gdrr_config,
662 self.cancelled.clone(),
663 )?;
664
665 Ok(self.not_worse_than_baselines(result, geometries, boundary, greedy))
666 }
667
668 fn alns(&self, geometries: &[Geometry2D], boundary: &Boundary2D) -> Result<SolveResult<f64>> {
670 let (time_limit, greedy) = {
671 let budget = self.search_budget_ms(10000);
672 let (greedy, remaining) = self.greedy_start(geometries, boundary, budget);
673 (remaining, greedy)
674 };
675 let alns_config = AlnsConfig::default()
676 .with_max_iterations(1000) .with_time_limit_ms(time_limit)
678 .with_segment_size(50) .with_scores(33.0, 9.0, 13.0)
680 .with_reaction_factor(0.15) .with_temperature(100.0, 0.999, 0.1); let result = run_alns_nesting(
684 geometries,
685 boundary,
686 &self.config,
687 &alns_config,
688 self.cancelled.clone(),
689 )?;
690
691 Ok(self.not_worse_than_baselines(result, geometries, boundary, greedy))
692 }
693
694 #[cfg(feature = "milp")]
707 fn milp_exact(
708 &self,
709 geometries: &[Geometry2D],
710 boundary: &Boundary2D,
711 ) -> Result<SolveResult<f64>> {
712 let exact_config = ExactConfig::default()
717 .with_time_limit_ms(milp_time_limit(self.config.time_limit_ms))
718 .with_max_items(15)
719 .with_rotation_steps(4)
720 .with_grid_step(milp_grid_step(geometries, boundary));
721
722 let result = run_nfp_cm_nesting(
723 geometries,
724 boundary,
725 &self.config,
726 &exact_config,
727 self.cancelled.clone(),
728 );
729
730 Ok(result)
731 }
732
733 #[cfg(feature = "milp")]
735 fn hybrid_exact(
736 &self,
737 geometries: &[Geometry2D],
738 boundary: &Boundary2D,
739 ) -> Result<SolveResult<f64>> {
740 let total_instances: usize = geometries.iter().map(|g| g.quantity()).sum();
742
743 if total_instances <= 15 {
745 let exact_config = ExactConfig::default()
749 .with_time_limit_ms(milp_time_limit(self.config.time_limit_ms / 2))
750 .with_max_items(15);
751
752 let exact_result = run_nfp_cm_nesting(
753 geometries,
754 boundary,
755 &self.config,
756 &exact_config,
757 self.cancelled.clone(),
758 );
759
760 if !exact_result.placements.is_empty() {
762 return Ok(exact_result);
763 }
764 }
765
766 self.alns(geometries, boundary)
768 }
769
770 fn bottom_left_fill_with_progress(
775 &self,
776 geometries: &[Geometry2D],
777 boundary: &Boundary2D,
778 callback: &ProgressCallback,
779 ) -> Result<SolveResult<f64>> {
780 self.bottom_left_fill_impl(geometries, boundary, Some(callback))
781 }
782
783 fn bottom_left_fill_impl(
791 &self,
792 geometries: &[Geometry2D],
793 boundary: &Boundary2D,
794 callback: Option<&ProgressCallback>,
795 ) -> Result<SolveResult<f64>> {
796 let start = Timer::now();
797 let mut result = SolveResult::new();
798 let mut placements = Vec::new();
799 let report = |info: ProgressInfo| {
800 if let Some(callback) = callback {
801 callback(info);
802 }
803 };
804
805 let axis = PackingAxis::of(boundary);
806 let to_ca = |x: f64, y: f64| match axis {
808 PackingAxis::Y => (x, y),
809 PackingAxis::X => (y, x),
810 };
811 let from_ca = |across: f64, along: f64| match axis {
812 PackingAxis::Y => (across, along),
813 PackingAxis::X => (along, across),
814 };
815
816 let (b_min, b_max) = boundary.aabb();
817 let margin = self.config.margin;
818 let spacing = self.config.spacing;
819 let (min_across, min_along) = to_ca(b_min[0] + margin, b_min[1] + margin);
820 let (max_across, max_along) = to_ca(b_max[0] - margin, b_max[1] - margin);
821
822 let hole_boxes: Vec<(f64, f64, f64, f64)> = boundary
829 .holes()
830 .iter()
831 .filter(|hole| !hole.is_empty())
832 .map(|hole| {
833 let (mut x0, mut y0) = (f64::INFINITY, f64::INFINITY);
834 let (mut x1, mut y1) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
835 for &(x, y) in hole {
836 x0 = x0.min(x);
837 y0 = y0.min(y);
838 x1 = x1.max(x);
839 y1 = y1.max(y);
840 }
841 let (a0, l0) = to_ca(x0 - margin, y0 - margin);
842 let (a1, l1) = to_ca(x1 + margin, y1 + margin);
843 (a0.min(a1), l0.min(l1), a0.max(a1), l0.max(l1))
844 })
845 .collect();
846
847 let blocking_hole_edge = |across: f64, along: f64, ea: f64, el: f64| -> Option<f64> {
851 hole_boxes
852 .iter()
853 .filter(|(a0, l0, a1, l1)| {
854 across < *a1 && across + ea > *a0 && along < *l1 && along + el > *l0
855 })
856 .map(|(_, _, a1, _)| *a1)
857 .fold(None, |far: Option<f64>, a1| {
858 Some(far.map_or(a1, |f| f.max(a1)))
859 })
860 };
861
862 let mut cursor_across = min_across;
863 let mut cursor_along = min_along;
864 let mut row_depth = 0.0_f64;
865 let mut total_placed_area = 0.0;
866 let total_pieces: usize = geometries.iter().map(|g| g.quantity()).sum();
867 let mut placed_count = 0usize;
868
869 report(
870 ProgressInfo::new()
871 .with_phase("BLF Placement")
872 .with_items(0, total_pieces)
873 .with_elapsed(0),
874 );
875
876 for geom in geometries {
877 geom.validate()?;
878
879 let rotation_angles: Vec<f64> = geom.rotations();
881
882 for instance in 0..geom.quantity() {
883 if self.cancelled.load(Ordering::Relaxed) {
884 result.computation_time_ms = start.elapsed_ms();
885 report(
886 ProgressInfo::new()
887 .with_phase("Cancelled")
888 .with_items(placed_count, total_pieces)
889 .with_elapsed(result.computation_time_ms)
890 .finished(),
891 );
892 return Ok(result);
893 }
894
895 if self.config.time_limit_ms > 0 && start.elapsed_ms() >= self.config.time_limit_ms
897 {
898 result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
899 result.utilization = total_placed_area / boundary.measure();
900 result.computation_time_ms = start.elapsed_ms();
901 result.placements = placements;
902 report(
903 ProgressInfo::new()
904 .with_phase("Time Limit Reached")
905 .with_items(placed_count, total_pieces)
906 .with_elapsed(result.computation_time_ms)
907 .finished(),
908 );
909 return Ok(result);
910 }
911
912 let mut best_fit: Option<BlfCandidate> = None;
915
916 for &rotation in &rotation_angles {
917 let (g_min, g_max) = geom.aabb_at_rotation(rotation);
918 let (extent_across, extent_along) =
919 to_ca(g_max[0] - g_min[0], g_max[1] - g_min[1]);
920
921 if extent_across > max_across - min_across
922 || extent_along > max_along - min_along
923 {
924 continue;
925 }
926
927 let mut place_across = cursor_across;
928 let mut place_along = cursor_along;
929 let mut depth = row_depth;
932 let mut opened_row = false;
933 let mut turns = 0usize;
939 let max_turns = 4 * (hole_boxes.len() + 1) * (hole_boxes.len() + 2) + 16;
940 let fits = loop {
941 if place_across + extent_across > max_across {
942 place_across = min_across;
943 place_along += depth + spacing;
944 depth = extent_along;
945 opened_row = true;
946 }
947 if place_along + extent_along > max_along {
948 break false;
949 }
950 match blocking_hole_edge(
951 place_across,
952 place_along,
953 extent_across,
954 extent_along,
955 ) {
956 None => break true,
957 Some(past) => place_across = past + spacing,
958 }
959 turns += 1;
960 if turns > max_turns {
961 break false;
962 }
963 };
964 if !fits {
965 continue;
966 }
967
968 let cost = |c: &BlfCandidate| {
973 if c.opened_row {
974 c.place_along - min_along + c.extent_along
975 } else {
976 c.place_across - min_across + c.extent_across
977 }
978 };
979 let candidate = BlfCandidate {
980 rotation,
981 extent_across,
982 extent_along,
983 place_across,
984 place_along,
985 opened_row,
986 };
987 let better = best_fit
988 .as_ref()
989 .is_none_or(|best| cost(&candidate) < cost(best) - 1e-6);
990 if better {
991 best_fit = Some(candidate);
992 }
993 }
994
995 let Some(BlfCandidate {
996 rotation,
997 extent_across,
998 extent_along,
999 place_across,
1000 place_along,
1001 opened_row,
1002 }) = best_fit
1003 else {
1004 result.unplaced.push(geom.id().clone());
1005 continue;
1006 };
1007 if opened_row {
1008 row_depth = 0.0;
1009 }
1010
1011 let geom_aabb = geom.aabb_at_rotation(rotation);
1012 let (place_x, place_y) = from_ca(place_across, place_along);
1013 let Some((x, y)) = clamp_placement_to_boundary_with_margin(
1014 place_x - geom_aabb.0[0],
1015 place_y - geom_aabb.0[1],
1016 geom_aabb,
1017 (b_min, b_max),
1018 margin,
1019 ) else {
1020 result.unplaced.push(geom.id().clone());
1021 continue;
1022 };
1023
1024 placements.push(Placement::new_2d(
1025 geom.id().clone(),
1026 instance,
1027 x,
1028 y,
1029 rotation,
1030 ));
1031 total_placed_area += geom.measure();
1032 placed_count += 1;
1033
1034 let (actual_across, actual_along) = to_ca(x + geom_aabb.0[0], y + geom_aabb.0[1]);
1036 cursor_across = actual_across + extent_across + spacing;
1037 cursor_along = actual_along;
1038 row_depth = row_depth.max(extent_along);
1039
1040 report(
1041 ProgressInfo::new()
1042 .with_phase("BLF Placement")
1043 .with_items(placed_count, total_pieces)
1044 .with_utilization(total_placed_area / boundary.measure())
1045 .with_elapsed(start.elapsed_ms()),
1046 );
1047 }
1048 }
1049
1050 result.placements = placements;
1051 result.boundaries_used = 1;
1052 result.utilization = total_placed_area / boundary.measure();
1053 result.computation_time_ms = start.elapsed_ms();
1054
1055 report(
1056 ProgressInfo::new()
1057 .with_phase("Complete")
1058 .with_items(placed_count, total_pieces)
1059 .with_utilization(result.utilization)
1060 .with_elapsed(result.computation_time_ms)
1061 .finished(),
1062 );
1063
1064 Ok(result)
1065 }
1066
1067 fn nfp_guided_blf_with_progress(
1069 &self,
1070 geometries: &[Geometry2D],
1071 boundary: &Boundary2D,
1072 callback: &ProgressCallback,
1073 ) -> Result<SolveResult<f64>> {
1074 let start = Timer::now();
1075 let mut result = SolveResult::new();
1076 let mut placements = Vec::new();
1077 let mut placed_geometries: Vec<PlacedGeometry> = Vec::new();
1078
1079 let margin = self.config.margin;
1080 let spacing = self.config.spacing;
1081 let boundary_polygon = inset_boundary(boundary, margin);
1082
1083 let mut total_placed_area = 0.0;
1084 let sample_step = self.compute_sample_step(geometries);
1085
1086 let total_pieces: usize = geometries.iter().map(|g| g.quantity()).sum();
1088 let mut placed_count = 0usize;
1089
1090 callback(
1092 ProgressInfo::new()
1093 .with_phase("NFP Placement")
1094 .with_items(0, total_pieces)
1095 .with_elapsed(0),
1096 );
1097
1098 for geom in geometries {
1099 geom.validate()?;
1100
1101 let rotation_angles: Vec<f64> = geom.rotations();
1103
1104 let mirror_candidates = mirror_candidates(geom);
1105
1106 for instance in 0..geom.quantity() {
1107 if self.cancelled.load(Ordering::Relaxed) {
1108 result.computation_time_ms = start.elapsed_ms();
1109 callback(
1110 ProgressInfo::new()
1111 .with_phase("Cancelled")
1112 .with_items(placed_count, total_pieces)
1113 .with_elapsed(result.computation_time_ms)
1114 .finished(),
1115 );
1116 return Ok(result);
1117 }
1118
1119 if self.config.time_limit_ms > 0 && start.elapsed_ms() >= self.config.time_limit_ms
1121 {
1122 result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
1123 result.utilization = total_placed_area / boundary.measure();
1124 result.computation_time_ms = start.elapsed_ms();
1125 result.placements = placements;
1126 callback(
1127 ProgressInfo::new()
1128 .with_phase("Time Limit Reached")
1129 .with_items(placed_count, total_pieces)
1130 .with_elapsed(result.computation_time_ms)
1131 .finished(),
1132 );
1133 return Ok(result);
1134 }
1135
1136 let mut best_placement: Option<(f64, f64, f64, bool)> = None;
1137
1138 for &rotation in &rotation_angles {
1139 for &mirror in mirror_candidates {
1140 let ifp = match compute_ifp_with_margin_and_mirror(
1141 &boundary_polygon,
1142 geom,
1143 rotation,
1144 0.0,
1146 mirror,
1147 ) {
1148 Ok(ifp) => ifp,
1149 Err(_) => continue,
1150 };
1151
1152 if ifp.is_empty() {
1153 continue;
1154 }
1155
1156 let mut nfps: Vec<Nfp> = Vec::new();
1157 for placed in &placed_geometries {
1158 let cache_key = (
1160 placed.geometry.id().as_str(),
1161 geom.id().as_str(),
1162 rotation - placed.rotation,
1163 placed.mirrored,
1164 mirror,
1165 );
1166
1167 let nfp_at_origin =
1170 match self.nfp_cache.get_or_compute_mirrored(cache_key, || {
1171 let placed_at_origin = placed.geometry.clone();
1172 compute_nfp_mirrored(
1173 &placed_at_origin,
1174 geom,
1175 rotation - placed.rotation,
1176 placed.mirrored,
1177 mirror,
1178 )
1179 .map(|nfp| offset_nfp(&nfp, spacing))
1183 }) {
1184 Ok(nfp) => nfp,
1185 Err(_) => continue,
1186 };
1187
1188 let rotated_nfp = rotate_nfp(&nfp_at_origin, placed.rotation);
1190 let translated_nfp = translate_nfp(&rotated_nfp, placed.position);
1191 nfps.push(translated_nfp);
1192 }
1193
1194 nfps.extend(hole_nfps(boundary, geom, rotation, mirror, margin));
1197 let nfp_refs: Vec<&Nfp> = nfps.iter().collect();
1198
1199 if let Some((x, y)) = find_bottom_left_placement(
1200 &ifp,
1201 &nfp_refs,
1202 sample_step,
1203 PackingAxis::of(boundary),
1204 ) {
1205 let is_better =
1206 match best_placement {
1207 None => true,
1208 Some((best_x, best_y, _, _)) => PackingAxis::of(boundary)
1209 .precedes((x, y), (best_x, best_y), 1e-6),
1210 };
1211 if is_better {
1212 best_placement = Some((x, y, rotation, mirror));
1213 }
1214 }
1215 }
1216 }
1217
1218 if let Some((x, y, rotation, mirror)) = best_placement {
1219 let geom_aabb = geom.aabb_at_rotation_mirrored(rotation, mirror);
1222 let boundary_aabb = boundary.aabb();
1223
1224 if let Some((clamped_x, clamped_y)) = clamp_placement_to_boundary_with_margin(
1225 x,
1226 y,
1227 geom_aabb,
1228 boundary_aabb,
1229 margin,
1230 ) {
1231 let placement = Placement::new_2d(
1232 geom.id().clone(),
1233 instance,
1234 clamped_x,
1235 clamped_y,
1236 rotation,
1237 )
1238 .with_mirrored(mirror);
1239 placements.push(placement);
1240 placed_geometries.push(
1241 PlacedGeometry::new(geom.clone(), (clamped_x, clamped_y), rotation)
1242 .with_mirrored(mirror),
1243 );
1244 total_placed_area += geom.measure();
1245 placed_count += 1;
1246
1247 callback(
1249 ProgressInfo::new()
1250 .with_phase("NFP Placement")
1251 .with_items(placed_count, total_pieces)
1252 .with_utilization(total_placed_area / boundary.measure())
1253 .with_elapsed(start.elapsed_ms()),
1254 );
1255 } else {
1256 result.unplaced.push(geom.id().clone());
1257 }
1258 } else {
1259 result.unplaced.push(geom.id().clone());
1260 }
1261 }
1262 }
1263
1264 result.placements = placements;
1265 result.boundaries_used = 1;
1266 result.utilization = total_placed_area / boundary.measure();
1267 result.computation_time_ms = start.elapsed_ms();
1268
1269 callback(
1271 ProgressInfo::new()
1272 .with_phase("Complete")
1273 .with_items(placed_count, total_pieces)
1274 .with_utilization(result.utilization)
1275 .with_elapsed(result.computation_time_ms)
1276 .finished(),
1277 );
1278
1279 Ok(result)
1280 }
1281
1282 fn validate_geometries(&self, geometries: &[Geometry2D]) -> Result<()> {
1293 use u_nesting_core::geometry::Geometry;
1294 self.config.validate()?;
1295 for geom in geometries {
1296 geom.validate()?;
1297 }
1298 u_nesting_core::geometry::ensure_unique_ids(geometries)
1299 }
1300
1301 pub fn solve_multi_strip(
1303 &self,
1304 geometries: &[Geometry2D],
1305 boundary: &Boundary2D,
1306 ) -> Result<SolveResult<f64>> {
1307 boundary.validate()?;
1308 self.validate_geometries(geometries)?;
1309 self.cancelled.store(false, Ordering::Relaxed);
1310
1311 let (b_min, b_max) = boundary.aabb();
1312 let strip_width = b_max[0] - b_min[0];
1313
1314 let mut final_result = SolveResult::new();
1315 let mut remaining_geometries: Vec<Geometry2D> = geometries.to_vec();
1316 let mut strip_index = 0;
1317 let max_strips = 100; let mut placed_total: std::collections::HashMap<String, usize> =
1322 std::collections::HashMap::new();
1323
1324 while !remaining_geometries.is_empty() && strip_index < max_strips {
1325 if self.cancelled.load(Ordering::Relaxed) {
1326 break;
1327 }
1328
1329 let strip_result = self.run_strategy(&remaining_geometries, boundary)?;
1331
1332 let strip_result = validate_and_filter_placements(
1334 strip_result,
1335 &remaining_geometries,
1336 boundary,
1337 self.config.margin,
1338 );
1339
1340 if strip_result.placements.is_empty() {
1341 break;
1344 }
1345
1346 let mut strip_placed: std::collections::HashMap<String, usize> =
1349 std::collections::HashMap::new();
1350
1351 for mut placement in strip_result.placements {
1353 let gid = placement.geometry_id.clone();
1354 let prior = placed_total.get(&gid).copied().unwrap_or(0);
1357 let in_strip = strip_placed.get(&gid).copied().unwrap_or(0);
1358 placement.instance = prior + in_strip;
1359 if !placement.position.is_empty() {
1361 placement.position[0] += strip_index as f64 * strip_width;
1362 }
1363 placement.boundary_index = strip_index;
1364 *strip_placed.entry(gid).or_insert(0) += 1;
1365 final_result.placements.push(placement);
1366 }
1367
1368 for (gid, cnt) in &strip_placed {
1372 *placed_total.entry(gid.clone()).or_insert(0) += cnt;
1373 }
1374 remaining_geometries = remaining_geometries
1375 .into_iter()
1376 .filter_map(|g| {
1377 let placed_here = strip_placed.get(g.id()).copied().unwrap_or(0);
1378 let new_quantity = g.quantity().saturating_sub(placed_here);
1379 if new_quantity == 0 {
1380 None
1381 } else {
1382 Some(g.with_quantity(new_quantity))
1383 }
1384 })
1385 .collect();
1386
1387 strip_index += 1;
1388 }
1389
1390 for g in &remaining_geometries {
1393 final_result.unplaced.push(g.id().clone());
1394 }
1395
1396 final_result.boundaries_used = strip_index;
1397 final_result.deduplicate_unplaced();
1398 final_result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
1400
1401 let (b_min, b_max) = boundary.aabb();
1403 let strip_height = b_max[1] - b_min[1]; let mut strip_stats_map: std::collections::HashMap<usize, (f64, f64, usize)> =
1407 std::collections::HashMap::new(); for placement in &final_result.placements {
1410 let strip_idx = placement.boundary_index;
1411 if let Some(geom) = geometries.iter().find(|g| g.id() == &placement.geometry_id) {
1413 use u_nesting_core::geometry::Geometry;
1414 let piece_area = geom.measure();
1415 let rotation = placement.rotation.first().copied().unwrap_or(0.0);
1416 let (_g_min, g_max) = geom.aabb_at_rotation(rotation);
1417 let local_x = placement.position[0] - (strip_idx as f64 * strip_width);
1420 let right_edge = local_x + g_max[0];
1421
1422 let entry = strip_stats_map.entry(strip_idx).or_insert((0.0, 0.0, 0));
1423 entry.0 = entry.0.max(right_edge); entry.1 += piece_area; entry.2 += 1; }
1427 }
1428
1429 use u_nesting_core::result::StripStats;
1431 let mut strip_stats: Vec<StripStats> = strip_stats_map
1432 .into_iter()
1433 .map(|(idx, (used_length, piece_area, count))| StripStats {
1434 strip_index: idx,
1435 used_length,
1436 piece_area,
1437 piece_count: count,
1438 strip_width, strip_height, })
1441 .collect();
1442 strip_stats.sort_by_key(|s| s.strip_index);
1443
1444 let total_piece_area: f64 = strip_stats.iter().map(|s| s.piece_area).sum();
1447 let total_material_used: f64 = strip_stats
1448 .iter()
1449 .map(|s| s.strip_height * s.used_length)
1450 .sum();
1451
1452 final_result.strip_stats = strip_stats;
1453 final_result.total_piece_area = total_piece_area;
1454 final_result.total_material_used = total_material_used;
1455
1456 if total_material_used > 0.0 {
1457 final_result.utilization = total_piece_area / total_material_used;
1458 }
1459
1460 Ok(final_result)
1461 }
1462}
1463
1464impl Solver for Nester2D {
1465 type Geometry = Geometry2D;
1466 type Boundary = Boundary2D;
1467 type Scalar = f64;
1468
1469 fn solve(
1470 &self,
1471 geometries: &[Self::Geometry],
1472 boundary: &Self::Boundary,
1473 ) -> Result<SolveResult<f64>> {
1474 boundary.validate()?;
1475 self.validate_geometries(geometries)?;
1476
1477 self.cancelled.store(false, Ordering::Relaxed);
1479
1480 let initial_result = self.run_strategy(geometries, boundary)?;
1481
1482 let mut result = validate_and_filter_placements(
1484 initial_result,
1485 geometries,
1486 boundary,
1487 self.config.margin,
1488 );
1489
1490 result.deduplicate_unplaced();
1492 result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
1495 Ok(result)
1496 }
1497
1498 fn solve_with_progress(
1499 &self,
1500 geometries: &[Self::Geometry],
1501 boundary: &Self::Boundary,
1502 callback: ProgressCallback,
1503 ) -> Result<SolveResult<f64>> {
1504 boundary.validate()?;
1505 self.validate_geometries(geometries)?;
1506
1507 self.cancelled.store(false, Ordering::Relaxed);
1509
1510 let initial_result = match self.config.strategy {
1511 Strategy::BottomLeftFill => {
1512 self.bottom_left_fill_with_progress(geometries, boundary, &callback)?
1513 }
1514 Strategy::NfpGuided => {
1515 self.nfp_guided_blf_with_progress(geometries, boundary, &callback)?
1516 }
1517 Strategy::GeneticAlgorithm => {
1518 let (greedy, remaining) =
1525 self.greedy_start(geometries, boundary, self.config.time_limit_ms);
1526 let mut ga_config = GaConfig::default()
1527 .with_population_size(self.config.population_size.min(30))
1528 .with_max_generations(self.config.max_generations.min(50))
1529 .with_crossover_rate(self.config.crossover_rate)
1530 .with_mutation_rate(self.config.mutation_rate);
1531
1532 if self.config.time_limit_ms > 0 {
1534 ga_config =
1535 ga_config.with_time_limit(std::time::Duration::from_millis(remaining));
1536 }
1537
1538 let ga_result = run_ga_nesting_with_progress(
1539 geometries,
1540 boundary,
1541 &self.config,
1542 ga_config,
1543 self.cancelled.clone(),
1544 callback,
1545 greedy.as_ref().map(|g| g.placements.as_slice()),
1546 )?;
1547 self.not_worse_than_baselines(ga_result, geometries, boundary, greedy)
1552 }
1553 _ => self.run_strategy(geometries, boundary)?,
1557 };
1558
1559 let mut result = validate_and_filter_placements(
1561 initial_result,
1562 geometries,
1563 boundary,
1564 self.config.margin,
1565 );
1566
1567 result.deduplicate_unplaced();
1569 result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
1572 Ok(result)
1573 }
1574
1575 fn cancel(&self) {
1576 self.cancelled.store(true, Ordering::Relaxed);
1577 }
1578}
1579
1580#[cfg(test)]
1581mod tests {
1582 #[cfg(feature = "milp")]
1583 mod milp_budget {
1584 use super::super::{milp_grid_step, milp_time_limit};
1585 use crate::{Boundary2D, Geometry2D};
1586
1587 #[test]
1590 fn an_unlimited_request_does_not_become_a_minute() {
1591 assert_eq!(milp_time_limit(0), u64::MAX / 2);
1592 }
1593
1594 #[test]
1597 fn a_short_request_is_kept_short() {
1598 assert_eq!(milp_time_limit(1_000), 1_000);
1599 assert_eq!(milp_time_limit(250), 250);
1600 }
1601
1602 #[test]
1606 fn the_grid_step_follows_the_smallest_piece() {
1607 let boundary = Boundary2D::rectangle(400.0, 100.0);
1608 let pieces = [
1609 Geometry2D::rectangle("wide", 80.0, 40.0),
1610 Geometry2D::rectangle("small", 20.0, 60.0),
1611 ];
1612 assert_eq!(milp_grid_step(&pieces, &boundary), 5.0);
1614 }
1615
1616 #[test]
1619 fn a_huge_sheet_keeps_the_step_from_getting_too_fine() {
1620 let boundary = Boundary2D::rectangle(100_000.0, 100.0);
1621 let pieces = [Geometry2D::rectangle("small", 4.0, 4.0)];
1622 assert_eq!(milp_grid_step(&pieces, &boundary), 100_000.0 / 256.0);
1623 }
1624
1625 #[test]
1627 fn the_step_never_goes_below_a_unit() {
1628 let boundary = Boundary2D::rectangle(10.0, 10.0);
1629 let pieces = [Geometry2D::rectangle("tiny", 0.5, 0.5)];
1630 assert_eq!(milp_grid_step(&pieces, &boundary), 1.0);
1631 }
1632 }
1633
1634 use super::*;
1635 use crate::placement_utils::polygon_centroid;
1636
1637 #[test]
1641 fn a_geometry_id_given_twice_is_refused_by_every_entry_point() {
1642 let geometries = [
1643 Geometry2D::rectangle("P", 10.0, 10.0),
1644 Geometry2D::rectangle("Q", 10.0, 10.0),
1645 Geometry2D::rectangle("P", 30.0, 5.0),
1646 ];
1647 let boundary = Boundary2D::rectangle(100.0, 100.0);
1648 let nester = Nester2D::default_config();
1649 let errors = [
1650 nester.solve(&geometries, &boundary).expect_err("solve"),
1651 nester
1652 .solve_multi_strip(&geometries, &boundary)
1653 .expect_err("solve_multi_strip"),
1654 nester
1655 .solve_with_progress(&geometries, &boundary, Box::new(|_| {}))
1656 .expect_err("solve_with_progress"),
1657 ];
1658 for err in errors {
1659 let msg = err.to_string();
1660 assert!(msg.contains("'P'"), "{msg}");
1661 assert!(msg.contains("positions 0 and 2"), "{msg}");
1662 }
1663 }
1664
1665 #[test]
1666 fn test_simple_nesting() {
1667 let geometries = vec![
1668 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(3),
1669 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
1670 ];
1671
1672 let boundary = Boundary2D::rectangle(100.0, 50.0);
1673 let nester = Nester2D::default_config();
1674
1675 let result = nester.solve(&geometries, &boundary).unwrap();
1676
1677 assert!(result.utilization > 0.0);
1678 assert!(result.placements.len() <= 5); }
1680
1681 #[test]
1682 fn test_placement_within_bounds() {
1683 let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
1684
1685 let boundary = Boundary2D::rectangle(50.0, 50.0);
1686 let config = Config::default().with_margin(5.0).with_spacing(2.0);
1687 let nester = Nester2D::new(config);
1688
1689 let result = nester.solve(&geometries, &boundary).unwrap();
1690
1691 assert_eq!(result.placements.len(), 4);
1693 assert!(result.unplaced.is_empty());
1694
1695 for p in &result.placements {
1697 assert!(p.position[0] >= 5.0);
1698 assert!(p.position[1] >= 5.0);
1699 }
1700 }
1701
1702 #[test]
1703 fn test_nfp_guided_basic() {
1704 let geometries = vec![
1705 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
1706 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(1),
1707 ];
1708
1709 let boundary = Boundary2D::rectangle(100.0, 50.0);
1710 let config = Config::default().with_strategy(Strategy::NfpGuided);
1711 let nester = Nester2D::new(config);
1712
1713 let result = nester.solve(&geometries, &boundary).unwrap();
1714
1715 assert!(result.utilization > 0.0);
1716 assert_eq!(result.placements.len(), 3); assert!(result.unplaced.is_empty());
1718 }
1719
1720 #[test]
1721 fn test_nfp_guided_with_spacing() {
1722 let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
1723
1724 let boundary = Boundary2D::rectangle(50.0, 50.0);
1725 let config = Config::default()
1726 .with_strategy(Strategy::NfpGuided)
1727 .with_margin(2.0)
1728 .with_spacing(3.0);
1729 let nester = Nester2D::new(config);
1730
1731 let result = nester.solve(&geometries, &boundary).unwrap();
1732
1733 assert_eq!(result.placements.len(), 4);
1735 assert!(result.unplaced.is_empty());
1736
1737 assert!(result.utilization > 0.0);
1739 }
1740
1741 #[test]
1742 fn test_nfp_guided_no_overlap() {
1743 let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(3)];
1744
1745 let boundary = Boundary2D::rectangle(100.0, 100.0);
1746 let config = Config::default().with_strategy(Strategy::NfpGuided);
1747 let nester = Nester2D::new(config);
1748
1749 let result = nester.solve(&geometries, &boundary).unwrap();
1750
1751 assert_eq!(result.placements.len(), 3);
1752
1753 for i in 0..result.placements.len() {
1755 for j in (i + 1)..result.placements.len() {
1756 let p1 = &result.placements[i];
1757 let p2 = &result.placements[j];
1758
1759 let r1_min_x = p1.position[0];
1761 let r1_max_x = p1.position[0] + 20.0;
1762 let r1_min_y = p1.position[1];
1763 let r1_max_y = p1.position[1] + 20.0;
1764
1765 let r2_min_x = p2.position[0];
1766 let r2_max_x = p2.position[0] + 20.0;
1767 let r2_min_y = p2.position[1];
1768 let r2_max_y = p2.position[1] + 20.0;
1769
1770 let overlaps_x = r1_min_x < r2_max_x - 0.01 && r1_max_x > r2_min_x + 0.01;
1772 let overlaps_y = r1_min_y < r2_max_y - 0.01 && r1_max_y > r2_min_y + 0.01;
1773
1774 assert!(
1775 !(overlaps_x && overlaps_y),
1776 "Placements {} and {} overlap",
1777 i,
1778 j
1779 );
1780 }
1781 }
1782 }
1783
1784 fn polygons_overlap(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
1789 for i in 0..a.len() {
1790 let a1 = a[i];
1791 let a2 = a[(i + 1) % a.len()];
1792 for j in 0..b.len() {
1793 let b1 = b[j];
1794 let b2 = b[(j + 1) % b.len()];
1795 if crate::polygon_ops::segments_intersect(a1, a2, b1, b2) {
1796 return true;
1797 }
1798 }
1799 }
1800 false
1801 }
1802
1803 #[test]
1804 fn test_mirror_candidates_reflects_allow_flip() {
1805 let plain = Geometry2D::rectangle("R", 10.0, 10.0);
1806 assert_eq!(mirror_candidates(&plain), &[false]);
1807
1808 let flippable = Geometry2D::rectangle("R", 10.0, 10.0).with_flip(true);
1809 assert_eq!(mirror_candidates(&flippable), &[false, true]);
1810 }
1811
1812 #[test]
1832 fn test_mirror_aware_placement_avoids_overlap() {
1833 let spacing = 1.0;
1841 let geom = Geometry2D::l_shape("L", 30.0, 20.0, 20.0, 10.0);
1842 let boundary_polygon = vec![(0.0, 0.0), (65.0, 0.0), (65.0, 45.0), (0.0, 45.0)];
1843 let cache = NfpCache::new();
1844
1845 let ifp1 =
1847 compute_ifp_with_margin_and_mirror(&boundary_polygon, &geom, 0.0, 0.0, false).unwrap();
1848 let (x1, y1) = find_bottom_left_placement(&ifp1, &[], 1.0, PackingAxis::X)
1849 .expect("first piece must fit");
1850 let placed1 = PlacedGeometry::new(geom.clone(), (x1, y1), 0.0).with_mirrored(false);
1851
1852 let ifp2 =
1854 compute_ifp_with_margin_and_mirror(&boundary_polygon, &geom, 0.0, 0.0, true).unwrap();
1855 let nfp_at_origin = cache
1856 .get_or_compute_mirrored(("L", "L", 0.0, false, true), || {
1857 compute_nfp_mirrored(&placed1.geometry, &geom, 0.0, false, true)
1858 })
1859 .unwrap();
1860 let translated_nfp = translate_nfp(&nfp_at_origin, placed1.position);
1861 let expanded_nfp = offset_nfp(&translated_nfp, spacing);
1862 let (x2, y2) = find_bottom_left_placement(&ifp2, &[&expanded_nfp], 1.0, PackingAxis::X)
1863 .expect("mirrored second piece must fit avoiding the first");
1864 let placed2 = PlacedGeometry::new(geom.clone(), (x2, y2), 0.0).with_mirrored(true);
1865
1866 assert_ne!(
1867 (x1, y1),
1868 (x2, y2),
1869 "mirrored placement must actually avoid the first piece's position"
1870 );
1871 assert!(
1872 !polygons_overlap(
1873 &placed1.translated_exterior(),
1874 &placed2.translated_exterior()
1875 ),
1876 "unmirrored piece at {:?} and mirrored piece at {:?} must not overlap",
1877 (x1, y1),
1878 (x2, y2)
1879 );
1880 }
1881
1882 #[test]
1883 fn test_nfp_guided_utilization() {
1884 let geometries = vec![Geometry2D::rectangle("R1", 25.0, 25.0).with_quantity(4)];
1886
1887 let boundary = Boundary2D::rectangle(100.0, 50.0);
1888 let config = Config::default().with_strategy(Strategy::NfpGuided);
1889 let nester = Nester2D::new(config);
1890
1891 let result = nester.solve(&geometries, &boundary).unwrap();
1892
1893 assert_eq!(result.placements.len(), 4);
1895
1896 assert!(result.utilization > 0.45);
1898 }
1899
1900 #[test]
1901 fn test_polygon_centroid() {
1902 let square = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
1904 let (cx, cy) = polygon_centroid(&square);
1905 assert!((cx - 5.0).abs() < 0.01);
1906 assert!((cy - 5.0).abs() < 0.01);
1907
1908 let triangle = vec![(0.0, 0.0), (6.0, 0.0), (3.0, 6.0)];
1909 let (cx, cy) = polygon_centroid(&triangle);
1910 assert!((cx - 3.0).abs() < 0.01);
1911 assert!((cy - 2.0).abs() < 0.01);
1912 }
1913
1914 #[test]
1915 fn test_ga_strategy_basic() {
1916 let geometries = vec![
1917 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
1918 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
1919 ];
1920
1921 let boundary = Boundary2D::rectangle(100.0, 50.0);
1922 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
1923 let nester = Nester2D::new(config);
1924
1925 let result = nester.solve(&geometries, &boundary).unwrap();
1926
1927 assert!(result.utilization > 0.0);
1928 assert!(!result.placements.is_empty());
1929 assert!(result.generations.is_some());
1931 assert!(result.best_fitness.is_some());
1932 assert!(result.strategy == Some("GeneticAlgorithm".to_string()));
1933 }
1934
1935 #[test]
1936 fn test_ga_strategy_all_placed() {
1937 let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
1939
1940 let boundary = Boundary2D::rectangle(100.0, 100.0);
1941 let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
1942 let nester = Nester2D::new(config);
1943
1944 let result = nester.solve(&geometries, &boundary).unwrap();
1945
1946 assert_eq!(result.placements.len(), 4);
1948 assert!(result.unplaced.is_empty());
1949 }
1950
1951 #[test]
1952 fn test_brkga_strategy_basic() {
1953 let geometries = vec![
1954 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
1955 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
1956 ];
1957
1958 let boundary = Boundary2D::rectangle(100.0, 50.0);
1959 let config = Config::default().with_strategy(Strategy::Brkga);
1960 let nester = Nester2D::new(config);
1961
1962 let result = nester.solve(&geometries, &boundary).unwrap();
1963
1964 assert!(result.utilization > 0.0);
1965 assert!(!result.placements.is_empty());
1966 assert!(result.generations.is_some());
1968 assert!(result.best_fitness.is_some());
1969 assert!(result.strategy == Some("BRKGA".to_string()));
1970 }
1971
1972 #[test]
1973 fn test_brkga_strategy_all_placed() {
1974 let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
1976
1977 let boundary = Boundary2D::rectangle(100.0, 100.0);
1978 let config = Config::default()
1980 .with_strategy(Strategy::Brkga)
1981 .with_time_limit(30000); let nester = Nester2D::new(config);
1983
1984 let result = nester.solve(&geometries, &boundary).unwrap();
1985
1986 assert!(
1989 result.placements.len() >= 3,
1990 "Expected at least 3 placements, got {}",
1991 result.placements.len()
1992 );
1993 }
1994
1995 #[test]
1996 fn test_gdrr_strategy_basic() {
1997 let geometries = vec![
1998 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
1999 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
2000 ];
2001
2002 let boundary = Boundary2D::rectangle(100.0, 50.0);
2003 let config = Config::default().with_strategy(Strategy::Gdrr);
2004 let nester = Nester2D::new(config);
2005
2006 let result = nester.solve(&geometries, &boundary).unwrap();
2007
2008 assert!(result.utilization > 0.0);
2009 assert!(!result.placements.is_empty());
2010 assert!(result.iterations.is_some());
2012 assert!(result.best_fitness.is_some());
2013 assert!(result.strategy == Some("GDRR".to_string()));
2014 }
2015
2016 #[test]
2017 fn test_gdrr_strategy_all_placed() {
2018 let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
2020
2021 let boundary = Boundary2D::rectangle(100.0, 100.0);
2022 let config = Config::default().with_strategy(Strategy::Gdrr);
2023 let nester = Nester2D::new(config);
2024
2025 let result = nester.solve(&geometries, &boundary).unwrap();
2026
2027 assert_eq!(result.placements.len(), 4);
2029 assert!(result.unplaced.is_empty());
2030 }
2031
2032 #[test]
2033 fn test_alns_strategy_basic() {
2034 let geometries = vec![
2035 Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
2036 Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
2037 ];
2038
2039 let boundary = Boundary2D::rectangle(100.0, 50.0);
2040 let config = Config::default().with_strategy(Strategy::Alns);
2041 let nester = Nester2D::new(config);
2042
2043 let result = nester.solve(&geometries, &boundary).unwrap();
2044
2045 assert!(result.utilization > 0.0);
2046 assert!(!result.placements.is_empty());
2047 assert!(result.iterations.is_some());
2049 assert!(result.best_fitness.is_some());
2050 assert!(result.strategy == Some("ALNS".to_string()));
2051 }
2052
2053 #[test]
2054 fn test_alns_strategy_all_placed() {
2055 let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
2057
2058 let boundary = Boundary2D::rectangle(100.0, 100.0);
2059 let config = Config::default().with_strategy(Strategy::Alns);
2060 let nester = Nester2D::new(config);
2061
2062 let result = nester.solve(&geometries, &boundary).unwrap();
2063
2064 assert_eq!(result.placements.len(), 4);
2066 assert!(result.unplaced.is_empty());
2067 }
2068
2069 #[test]
2070 fn test_blf_rotation_optimization() {
2071 let geometries = vec![Geometry2D::rectangle("R1", 30.0, 10.0)
2074 .with_rotations(vec![0.0, std::f64::consts::FRAC_PI_2]) .with_quantity(3)];
2076
2077 let boundary = Boundary2D::rectangle(35.0, 95.0);
2080 let nester = Nester2D::default_config();
2081
2082 let result = nester.solve(&geometries, &boundary).unwrap();
2083
2084 assert_eq!(
2086 result.placements.len(),
2087 3,
2088 "All pieces should be placed with rotation optimization"
2089 );
2090 assert!(result.unplaced.is_empty());
2091 }
2092
2093 #[test]
2094 fn test_blf_selects_best_rotation() {
2095 let geometries = vec![Geometry2D::rectangle("R1", 40.0, 10.0)
2097 .with_rotations(vec![0.0, std::f64::consts::FRAC_PI_2]) .with_quantity(2)];
2099
2100 let boundary = Boundary2D::rectangle(45.0, 50.0);
2104 let nester = Nester2D::default_config();
2105
2106 let result = nester.solve(&geometries, &boundary).unwrap();
2107
2108 assert_eq!(result.placements.len(), 2);
2109 assert!(result.unplaced.is_empty());
2110 }
2111
2112 #[test]
2113 fn test_progress_callback_blf() {
2114 use std::sync::atomic::{AtomicUsize, Ordering};
2115 use std::sync::Arc;
2116
2117 let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
2118 let boundary = Boundary2D::rectangle(50.0, 50.0);
2119 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2120 let nester = Nester2D::new(config);
2121
2122 let callback_count = Arc::new(AtomicUsize::new(0));
2123 let callback_count_clone = callback_count.clone();
2124 let last_items_placed = Arc::new(AtomicUsize::new(0));
2125 let last_items_placed_clone = last_items_placed.clone();
2126
2127 let callback: ProgressCallback = Box::new(move |info| {
2128 callback_count_clone.fetch_add(1, Ordering::Relaxed);
2129 last_items_placed_clone.store(info.items_placed, Ordering::Relaxed);
2130 });
2131
2132 let result = nester
2133 .solve_with_progress(&geometries, &boundary, callback)
2134 .unwrap();
2135
2136 let count = callback_count.load(Ordering::Relaxed);
2138 assert!(
2139 count >= 5,
2140 "Expected at least 5 callbacks (1 initial + 4 pieces + 1 final), got {}",
2141 count
2142 );
2143
2144 let final_placed = last_items_placed.load(Ordering::Relaxed);
2146 assert_eq!(final_placed, 4, "Should report 4 items placed");
2147
2148 assert_eq!(result.placements.len(), 4);
2150 }
2151
2152 #[test]
2153 fn test_progress_callback_nfp() {
2154 use std::sync::atomic::{AtomicUsize, Ordering};
2155 use std::sync::Arc;
2156
2157 let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(2)];
2158 let boundary = Boundary2D::rectangle(50.0, 50.0);
2159 let config = Config::default().with_strategy(Strategy::NfpGuided);
2160 let nester = Nester2D::new(config);
2161
2162 let callback_count = Arc::new(AtomicUsize::new(0));
2163 let callback_count_clone = callback_count.clone();
2164
2165 let callback: ProgressCallback = Box::new(move |info| {
2166 callback_count_clone.fetch_add(1, Ordering::Relaxed);
2167 assert!(info.items_placed <= info.total_items);
2168 });
2169
2170 let result = nester
2171 .solve_with_progress(&geometries, &boundary, callback)
2172 .unwrap();
2173
2174 let count = callback_count.load(Ordering::Relaxed);
2176 assert!(count >= 3, "Expected at least 3 callbacks, got {}", count);
2177
2178 assert_eq!(result.placements.len(), 2);
2180 }
2181
2182 #[test]
2183 fn test_time_limit_honored() {
2184 let geometries: Vec<Geometry2D> = (0..100)
2186 .map(|i| Geometry2D::rectangle(format!("R{}", i), 5.0, 5.0))
2187 .collect();
2188 let boundary = Boundary2D::rectangle(1000.0, 1000.0);
2189
2190 let config = Config::default()
2192 .with_strategy(Strategy::BottomLeftFill)
2193 .with_time_limit(1);
2194 let nester = Nester2D::new(config);
2195
2196 let result = nester.solve(&geometries, &boundary).unwrap();
2197
2198 assert!(
2201 result.computation_time_ms <= 100, "Computation took too long: {}ms (expected <= 100ms with 1ms limit)",
2203 result.computation_time_ms
2204 );
2205 }
2206
2207 #[test]
2208 fn test_time_limit_zero_unlimited() {
2209 let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(4)];
2211 let boundary = Boundary2D::rectangle(50.0, 50.0);
2212
2213 let config = Config::default()
2214 .with_strategy(Strategy::BottomLeftFill)
2215 .with_time_limit(0); let nester = Nester2D::new(config);
2217
2218 let result = nester.solve(&geometries, &boundary).unwrap();
2219
2220 assert_eq!(result.placements.len(), 4);
2222 }
2223
2224 #[test]
2225 fn test_blf_bounds_clamping() {
2226 let gear_like = Geometry2D::new("gear")
2230 .with_polygon(vec![
2231 (50.0, 5.0), (65.0, 15.0),
2233 (77.0, 18.0),
2234 (80.0, 32.0),
2235 (95.0, 50.0), (80.0, 68.0),
2237 (77.0, 82.0),
2238 (65.0, 85.0),
2239 (50.0, 95.0), (35.0, 85.0),
2241 (23.0, 82.0),
2242 (20.0, 68.0),
2243 (5.0, 50.0), (20.0, 32.0),
2245 (23.0, 18.0),
2246 (35.0, 15.0),
2247 ])
2248 .with_quantity(1);
2249
2250 let boundary = Boundary2D::rectangle(100.0, 100.0);
2252
2253 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2254 let nester = Nester2D::new(config);
2255
2256 let result = nester
2257 .solve(std::slice::from_ref(&gear_like), &boundary)
2258 .unwrap();
2259
2260 assert_eq!(result.placements.len(), 1);
2261 let placement = &result.placements[0];
2262
2263 let origin_x = placement.position[0];
2265 let origin_y = placement.position[1];
2266
2267 let rotation = placement.rotation.first().copied().unwrap_or(0.0);
2269
2270 let (g_min, g_max) = gear_like.aabb_at_rotation(rotation);
2272
2273 let actual_min_x = origin_x + g_min[0];
2275 let actual_max_x = origin_x + g_max[0];
2276 let actual_min_y = origin_y + g_min[1];
2277 let actual_max_y = origin_y + g_max[1];
2278
2279 assert!(
2281 actual_min_x >= 0.0,
2282 "Left edge {} should be >= 0",
2283 actual_min_x
2284 );
2285 assert!(
2286 actual_max_x <= 100.0,
2287 "Right edge {} should be <= 100",
2288 actual_max_x
2289 );
2290 assert!(
2291 actual_min_y >= 0.0,
2292 "Bottom edge {} should be >= 0",
2293 actual_min_y
2294 );
2295 assert!(
2296 actual_max_y <= 100.0,
2297 "Top edge {} should be <= 100",
2298 actual_max_y
2299 );
2300 }
2301
2302 #[test]
2303 fn test_blf_bounds_clamping_many_pieces() {
2304 let gear_like = Geometry2D::new("gear")
2307 .with_polygon(vec![
2308 (50.0, 5.0),
2309 (65.0, 15.0),
2310 (77.0, 18.0),
2311 (80.0, 32.0),
2312 (95.0, 50.0),
2313 (80.0, 68.0),
2314 (77.0, 82.0),
2315 (65.0, 85.0),
2316 (50.0, 95.0),
2317 (35.0, 85.0),
2318 (23.0, 82.0),
2319 (20.0, 68.0),
2320 (5.0, 50.0),
2321 (20.0, 32.0),
2322 (23.0, 18.0),
2323 (35.0, 15.0),
2324 ])
2325 .with_quantity(13); let boundary = Boundary2D::rectangle(500.0, 500.0);
2329
2330 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2331 let nester = Nester2D::new(config);
2332
2333 let result = nester
2334 .solve(std::slice::from_ref(&gear_like), &boundary)
2335 .unwrap();
2336
2337 for (i, placement) in result.placements.iter().enumerate() {
2339 let origin_x = placement.position[0];
2340 let origin_y = placement.position[1];
2341 let rotation = placement.rotation.first().copied().unwrap_or(0.0);
2342
2343 let (g_min, g_max) = gear_like.aabb_at_rotation(rotation);
2344
2345 let actual_min_x = origin_x + g_min[0];
2346 let actual_max_x = origin_x + g_max[0];
2347 let actual_min_y = origin_y + g_min[1];
2348 let actual_max_y = origin_y + g_max[1];
2349
2350 assert!(
2351 actual_min_x >= -0.01,
2352 "Piece {}: Left edge {} should be >= 0",
2353 i,
2354 actual_min_x
2355 );
2356 assert!(
2357 actual_max_x <= 500.01,
2358 "Piece {}: Right edge {} should be <= 500",
2359 i,
2360 actual_max_x
2361 );
2362 assert!(
2363 actual_min_y >= -0.01,
2364 "Piece {}: Bottom edge {} should be >= 0",
2365 i,
2366 actual_min_y
2367 );
2368 assert!(
2369 actual_max_y <= 500.01,
2370 "Piece {}: Top edge {} should be <= 500",
2371 i,
2372 actual_max_y
2373 );
2374 }
2375 }
2376
2377 #[test]
2378 fn test_blf_bounds_trace() {
2379 let gear = Geometry2D::new("gear").with_polygon(vec![
2381 (50.0, 5.0),
2382 (65.0, 15.0),
2383 (77.0, 18.0),
2384 (80.0, 32.0),
2385 (95.0, 50.0),
2386 (80.0, 68.0),
2387 (77.0, 82.0),
2388 (65.0, 85.0),
2389 (50.0, 95.0),
2390 (35.0, 85.0),
2391 (23.0, 82.0),
2392 (20.0, 68.0),
2393 (5.0, 50.0),
2394 (20.0, 32.0),
2395 (23.0, 18.0),
2396 (35.0, 15.0),
2397 ]);
2398
2399 let (g_min, g_max) = gear.aabb();
2401 println!("Gear AABB: min={:?}, max={:?}", g_min, g_max);
2402 assert!(
2403 (g_min[0] - 5.0).abs() < 0.01,
2404 "g_min[0] should be 5, got {}",
2405 g_min[0]
2406 );
2407 assert!(
2408 (g_max[0] - 95.0).abs() < 0.01,
2409 "g_max[0] should be 95, got {}",
2410 g_max[0]
2411 );
2412
2413 let b_max_x = 500.0;
2415 let margin = 0.0;
2416 let max_valid_x = b_max_x - margin - g_max[0];
2417 println!(
2418 "max_valid_x = {} - {} - {} = {}",
2419 b_max_x, margin, g_max[0], max_valid_x
2420 );
2421 assert!(
2422 (max_valid_x - 405.0).abs() < 0.01,
2423 "max_valid_x should be 405, got {}",
2424 max_valid_x
2425 );
2426
2427 let boundary = Boundary2D::rectangle(500.0, 500.0);
2429 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2430 let nester = Nester2D::new(config);
2431
2432 let result = nester
2433 .solve(&[gear.clone().with_quantity(1)], &boundary)
2434 .unwrap();
2435
2436 assert_eq!(result.placements.len(), 1);
2437 let p = &result.placements[0];
2438 let origin_x = p.position[0];
2439 let rotation = p.rotation.first().copied().unwrap_or(0.0);
2440
2441 let (g_min_r, g_max_r) = gear.aabb_at_rotation(rotation);
2442 let actual_max_x = origin_x + g_max_r[0];
2443
2444 println!("Placement: origin_x={}, rotation={}", origin_x, rotation);
2445 println!(
2446 "At rotation {}: g_min={:?}, g_max={:?}",
2447 rotation, g_min_r, g_max_r
2448 );
2449 println!(
2450 "Actual max x: {} + {} = {}",
2451 origin_x, g_max_r[0], actual_max_x
2452 );
2453
2454 assert!(
2455 actual_max_x <= 500.01,
2456 "Geometry exceeds boundary: max_x={} > 500",
2457 actual_max_x
2458 );
2459 }
2460
2461 #[test]
2462 fn test_blf_bounds_many_pieces_direct() {
2463 let gear = Geometry2D::new("gear")
2465 .with_polygon(vec![
2466 (50.0, 5.0),
2467 (65.0, 15.0),
2468 (77.0, 18.0),
2469 (80.0, 32.0),
2470 (95.0, 50.0),
2471 (80.0, 68.0),
2472 (77.0, 82.0),
2473 (65.0, 85.0),
2474 (50.0, 95.0),
2475 (35.0, 85.0),
2476 (23.0, 82.0),
2477 (20.0, 68.0),
2478 (5.0, 50.0),
2479 (20.0, 32.0),
2480 (23.0, 18.0),
2481 (35.0, 15.0),
2482 ])
2483 .with_quantity(25); let boundary = Boundary2D::rectangle(500.0, 500.0);
2486 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2487 let nester = Nester2D::new(config);
2488
2489 let result = nester
2490 .solve(std::slice::from_ref(&gear), &boundary)
2491 .unwrap();
2492
2493 println!("Placed {} pieces", result.placements.len());
2494
2495 for (i, p) in result.placements.iter().enumerate() {
2497 let origin_x = p.position[0];
2498 let origin_y = p.position[1];
2499 let rotation = p.rotation.first().copied().unwrap_or(0.0);
2500
2501 let (g_min_r, g_max_r) = gear.aabb_at_rotation(rotation);
2502
2503 let actual_min_x = origin_x + g_min_r[0];
2504 let actual_max_x = origin_x + g_max_r[0];
2505 let actual_min_y = origin_y + g_min_r[1];
2506 let actual_max_y = origin_y + g_max_r[1];
2507
2508 println!(
2509 "Piece {}: origin=({:.1}, {:.1}), rot={:.2}, bounds=[{:.1},{:.1}]x[{:.1},{:.1}]",
2510 i,
2511 origin_x,
2512 origin_y,
2513 rotation,
2514 actual_min_x,
2515 actual_max_x,
2516 actual_min_y,
2517 actual_max_y
2518 );
2519
2520 assert!(
2521 actual_max_x <= 500.01,
2522 "Piece {}: Right edge {} > 500",
2523 i,
2524 actual_max_x
2525 );
2526 assert!(
2527 actual_max_y <= 500.01,
2528 "Piece {}: Top edge {} > 500",
2529 i,
2530 actual_max_y
2531 );
2532 }
2533 }
2534
2535 #[test]
2536 fn test_blf_bounds_multi_strip() {
2537 let gear = Geometry2D::new("gear")
2539 .with_polygon(vec![
2540 (50.0, 5.0),
2541 (65.0, 15.0),
2542 (77.0, 18.0),
2543 (80.0, 32.0),
2544 (95.0, 50.0),
2545 (80.0, 68.0),
2546 (77.0, 82.0),
2547 (65.0, 85.0),
2548 (50.0, 95.0),
2549 (35.0, 85.0),
2550 (23.0, 82.0),
2551 (20.0, 68.0),
2552 (5.0, 50.0),
2553 (20.0, 32.0),
2554 (23.0, 18.0),
2555 (35.0, 15.0),
2556 ])
2557 .with_quantity(50); let boundary = Boundary2D::rectangle(500.0, 500.0);
2560 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2561 let nester = Nester2D::new(config);
2562
2563 let result = nester
2565 .solve_multi_strip(std::slice::from_ref(&gear), &boundary)
2566 .unwrap();
2567
2568 println!(
2569 "Placed {} pieces across {} strips",
2570 result.placements.len(),
2571 result.boundaries_used
2572 );
2573
2574 let strip_width = 500.0;
2576 for (i, p) in result.placements.iter().enumerate() {
2577 let origin_x = p.position[0];
2578 let origin_y = p.position[1];
2579 let rotation = p.rotation.first().copied().unwrap_or(0.0);
2580 let strip_idx = p.boundary_index;
2581
2582 let local_x = origin_x - (strip_idx as f64 * strip_width);
2584
2585 let (_g_min_r, g_max_r) = gear.aabb_at_rotation(rotation);
2586
2587 let local_max_x = local_x + g_max_r[0];
2588 let local_max_y = origin_y + g_max_r[1];
2589
2590 println!(
2591 "Piece {}: strip={}, origin=({:.1}, {:.1}), local_x={:.1}, rot={:.2}, local_max_x={:.1}",
2592 i, strip_idx, origin_x, origin_y, local_x, rotation, local_max_x
2593 );
2594
2595 assert!(
2596 local_max_x <= 500.01,
2597 "Piece {}: In strip {}, local right edge {:.1} > 500",
2598 i,
2599 strip_idx,
2600 local_max_x
2601 );
2602 assert!(
2603 local_max_y <= 500.01,
2604 "Piece {}: Top edge {:.1} > 500",
2605 i,
2606 local_max_y
2607 );
2608 }
2609 }
2610
2611 #[test]
2612 fn test_blf_bounds_mixed_shapes() {
2613 let shapes = vec![
2615 Geometry2D::new("shape0")
2617 .with_polygon(vec![
2618 (0.0, 0.0),
2619 (180.0, 0.0),
2620 (195.0, 15.0),
2621 (200.0, 50.0),
2622 (200.0, 150.0),
2623 (195.0, 185.0),
2624 (180.0, 200.0),
2625 (20.0, 200.0),
2626 (5.0, 185.0),
2627 (0.0, 150.0),
2628 (0.0, 50.0),
2629 (5.0, 15.0),
2630 ])
2631 .with_quantity(2),
2632 Geometry2D::new("shape1")
2634 .with_polygon(vec![
2635 (60.0, 0.0),
2636 (85.0, 7.0),
2637 (104.0, 25.0),
2638 (118.0, 50.0),
2639 (120.0, 60.0),
2640 (118.0, 70.0),
2641 (104.0, 95.0),
2642 (85.0, 113.0),
2643 (60.0, 120.0),
2644 (35.0, 113.0),
2645 (16.0, 95.0),
2646 (2.0, 70.0),
2647 (0.0, 60.0),
2648 (2.0, 50.0),
2649 (16.0, 25.0),
2650 (35.0, 7.0),
2651 ])
2652 .with_quantity(4),
2653 Geometry2D::new("shape2")
2655 .with_polygon(vec![
2656 (0.0, 0.0),
2657 (80.0, 0.0),
2658 (80.0, 20.0),
2659 (20.0, 20.0),
2660 (20.0, 80.0),
2661 (0.0, 80.0),
2662 ])
2663 .with_quantity(6),
2664 Geometry2D::new("shape3")
2666 .with_polygon(vec![(0.0, 0.0), (70.0, 0.0), (0.0, 70.0)])
2667 .with_quantity(6),
2668 Geometry2D::new("shape4")
2670 .with_polygon(vec![(0.0, 0.0), (120.0, 0.0), (120.0, 60.0), (0.0, 60.0)])
2671 .with_quantity(4),
2672 Geometry2D::new("shape5")
2674 .with_polygon(vec![
2675 (15.0, 0.0),
2676 (45.0, 0.0),
2677 (60.0, 26.0),
2678 (45.0, 52.0),
2679 (15.0, 52.0),
2680 (0.0, 26.0),
2681 ])
2682 .with_quantity(8),
2683 Geometry2D::new("shape6")
2685 .with_polygon(vec![
2686 (0.0, 0.0),
2687 (90.0, 0.0),
2688 (90.0, 12.0),
2689 (55.0, 12.0),
2690 (55.0, 60.0),
2691 (35.0, 60.0),
2692 (35.0, 12.0),
2693 (0.0, 12.0),
2694 ])
2695 .with_quantity(4),
2696 Geometry2D::new("shape7")
2698 .with_polygon(vec![
2699 (0.0, 10.0),
2700 (10.0, 0.0),
2701 (70.0, 0.0),
2702 (80.0, 10.0),
2703 (80.0, 70.0),
2704 (70.0, 80.0),
2705 (10.0, 80.0),
2706 (0.0, 70.0),
2707 ])
2708 .with_quantity(3),
2709 Geometry2D::new("shape8_gear")
2711 .with_polygon(vec![
2712 (50.0, 5.0),
2713 (65.0, 15.0),
2714 (77.0, 18.0),
2715 (80.0, 32.0),
2716 (95.0, 50.0),
2717 (80.0, 68.0),
2718 (77.0, 82.0),
2719 (65.0, 85.0),
2720 (50.0, 95.0),
2721 (35.0, 85.0),
2722 (23.0, 82.0),
2723 (20.0, 68.0),
2724 (5.0, 50.0),
2725 (20.0, 32.0),
2726 (23.0, 18.0),
2727 (35.0, 15.0),
2728 ])
2729 .with_quantity(13),
2730 ];
2731
2732 let boundary = Boundary2D::rectangle(500.0, 500.0);
2734 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2735 let nester = Nester2D::new(config);
2736
2737 let result = nester.solve_multi_strip(&shapes, &boundary).unwrap();
2738
2739 println!(
2740 "Placed {} pieces across {} strips",
2741 result.placements.len(),
2742 result.boundaries_used
2743 );
2744
2745 let strip_width = 500.0;
2747 let gear_aabb = shapes[8].aabb();
2748 println!("Gear AABB: min={:?}, max={:?}", gear_aabb.0, gear_aabb.1);
2749
2750 let mut violations = Vec::new();
2751 for p in &result.placements {
2752 if p.geometry_id.as_str().starts_with("shape8") {
2753 let origin_x = p.position[0];
2754 let _origin_y = p.position[1];
2755 let rotation = p.rotation.first().copied().unwrap_or(0.0);
2756 let strip_idx = p.boundary_index;
2757 let local_x = origin_x - (strip_idx as f64 * strip_width);
2758
2759 let (_g_min_r, g_max_r) = shapes[8].aabb_at_rotation(rotation);
2760 let local_max_x = local_x + g_max_r[0];
2761
2762 println!(
2763 "{}: strip={}, local_x={:.1}, rot={:.2}, local_max_x={:.1}",
2764 p.geometry_id, strip_idx, local_x, rotation, local_max_x
2765 );
2766
2767 if local_max_x > 500.01 {
2768 violations.push((p.geometry_id.clone(), strip_idx, local_x, local_max_x));
2769 }
2770 }
2771 }
2772
2773 assert!(
2774 violations.is_empty(),
2775 "Found {} Gear pieces exceeding boundary: {:?}",
2776 violations.len(),
2777 violations
2778 );
2779 }
2780
2781 #[test]
2782 fn test_blf_bounds_expanded_like_benchmark() {
2783 type ShapeDef = (Vec<(f64, f64)>, usize, Vec<f64>);
2787 let shape_defs: Vec<ShapeDef> = vec![
2788 (
2789 vec![
2790 (0.0, 0.0),
2791 (180.0, 0.0),
2792 (195.0, 15.0),
2793 (200.0, 50.0),
2794 (200.0, 150.0),
2795 (195.0, 185.0),
2796 (180.0, 200.0),
2797 (20.0, 200.0),
2798 (5.0, 185.0),
2799 (0.0, 150.0),
2800 (0.0, 50.0),
2801 (5.0, 15.0),
2802 ],
2803 2,
2804 vec![0.0, 90.0, 180.0, 270.0],
2805 ),
2806 (
2807 vec![
2808 (60.0, 0.0),
2809 (85.0, 7.0),
2810 (104.0, 25.0),
2811 (118.0, 50.0),
2812 (120.0, 60.0),
2813 (118.0, 70.0),
2814 (104.0, 95.0),
2815 (85.0, 113.0),
2816 (60.0, 120.0),
2817 (35.0, 113.0),
2818 (16.0, 95.0),
2819 (2.0, 70.0),
2820 (0.0, 60.0),
2821 (2.0, 50.0),
2822 (16.0, 25.0),
2823 (35.0, 7.0),
2824 ],
2825 4,
2826 vec![0.0, 45.0, 90.0, 135.0],
2827 ),
2828 (
2829 vec![
2830 (0.0, 0.0),
2831 (80.0, 0.0),
2832 (80.0, 20.0),
2833 (20.0, 20.0),
2834 (20.0, 80.0),
2835 (0.0, 80.0),
2836 ],
2837 6,
2838 vec![0.0, 90.0, 180.0, 270.0],
2839 ),
2840 (
2841 vec![(0.0, 0.0), (70.0, 0.0), (0.0, 70.0)],
2842 6,
2843 vec![0.0, 90.0, 180.0, 270.0],
2844 ),
2845 (
2846 vec![(0.0, 0.0), (120.0, 0.0), (120.0, 60.0), (0.0, 60.0)],
2847 4,
2848 vec![0.0, 90.0],
2849 ),
2850 (
2851 vec![
2852 (15.0, 0.0),
2853 (45.0, 0.0),
2854 (60.0, 26.0),
2855 (45.0, 52.0),
2856 (15.0, 52.0),
2857 (0.0, 26.0),
2858 ],
2859 8,
2860 vec![0.0, 60.0, 120.0],
2861 ),
2862 (
2863 vec![
2864 (0.0, 0.0),
2865 (90.0, 0.0),
2866 (90.0, 12.0),
2867 (55.0, 12.0),
2868 (55.0, 60.0),
2869 (35.0, 60.0),
2870 (35.0, 12.0),
2871 (0.0, 12.0),
2872 ],
2873 4,
2874 vec![0.0, 90.0, 180.0, 270.0],
2875 ),
2876 (
2877 vec![
2878 (0.0, 10.0),
2879 (10.0, 0.0),
2880 (70.0, 0.0),
2881 (80.0, 10.0),
2882 (80.0, 70.0),
2883 (70.0, 80.0),
2884 (10.0, 80.0),
2885 (0.0, 70.0),
2886 ],
2887 3,
2888 vec![0.0, 90.0],
2889 ),
2890 (
2892 vec![
2893 (50.0, 5.0),
2894 (65.0, 15.0),
2895 (77.0, 18.0),
2896 (80.0, 32.0),
2897 (95.0, 50.0),
2898 (80.0, 68.0),
2899 (77.0, 82.0),
2900 (65.0, 85.0),
2901 (50.0, 95.0),
2902 (35.0, 85.0),
2903 (23.0, 82.0),
2904 (20.0, 68.0),
2905 (5.0, 50.0),
2906 (20.0, 32.0),
2907 (23.0, 18.0),
2908 (35.0, 15.0),
2909 ],
2910 13,
2911 vec![0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0],
2912 ),
2913 ];
2914
2915 let mut geometries = Vec::new();
2917 let mut piece_id = 0;
2918 for (vertices, demand, rotations) in shape_defs.iter() {
2919 for _ in 0..*demand {
2920 let geom = Geometry2D::new(format!("piece_{}", piece_id))
2921 .with_polygon(vertices.clone())
2922 .with_rotations_deg(rotations.clone());
2923 geometries.push(geom);
2924 piece_id += 1;
2925 }
2926 }
2927
2928 let gear_geom = Geometry2D::new("gear_check").with_polygon(shape_defs[8].0.clone());
2930 let (gear_min, gear_max) = gear_geom.aabb();
2931 println!("Gear AABB: min={:?}, max={:?}", gear_min, gear_max);
2932
2933 let boundary = Boundary2D::rectangle(500.0, 500.0);
2934 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
2935 let nester = Nester2D::new(config);
2936
2937 let result = nester.solve_multi_strip(&geometries, &boundary).unwrap();
2938
2939 println!(
2940 "Placed {} pieces across {} strips",
2941 result.placements.len(),
2942 result.boundaries_used
2943 );
2944
2945 let strip_width = 500.0;
2947 let mut violations = Vec::new();
2948
2949 for p in &result.placements {
2950 let id_num: usize = p
2951 .geometry_id
2952 .as_str()
2953 .strip_prefix("piece_")
2954 .and_then(|s| s.parse().ok())
2955 .unwrap_or(0);
2956
2957 if (37..=49).contains(&id_num) {
2959 let origin_x = p.position[0];
2960 let rotation = p.rotation.first().copied().unwrap_or(0.0);
2961 let strip_idx = p.boundary_index;
2962 let local_x = origin_x - (strip_idx as f64 * strip_width);
2963
2964 let (_, g_max_r) = gear_geom.aabb_at_rotation(rotation);
2965 let local_max_x = local_x + g_max_r[0];
2966
2967 println!(
2968 "{}: strip={}, local_x={:.1}, rot={:.2}, local_max_x={:.1}",
2969 p.geometry_id, strip_idx, local_x, rotation, local_max_x
2970 );
2971
2972 if local_max_x > 500.01 {
2973 violations.push((p.geometry_id.clone(), strip_idx, local_x, local_max_x));
2974 }
2975 }
2976 }
2977
2978 assert!(
2979 violations.is_empty(),
2980 "Found {} Gear pieces exceeding boundary: {:?}",
2981 violations.len(),
2982 violations
2983 );
2984 }
2985
2986 fn aabbs_overlap(
2988 a_min: [f64; 2],
2989 a_max: [f64; 2],
2990 b_min: [f64; 2],
2991 b_max: [f64; 2],
2992 tolerance: f64,
2993 ) -> bool {
2994 let x_overlap = a_min[0] < b_max[0] - tolerance && a_max[0] > b_min[0] + tolerance;
2996 let y_overlap = a_min[1] < b_max[1] - tolerance && a_max[1] > b_min[1] + tolerance;
2997 x_overlap && y_overlap
2998 }
2999
3000 #[test]
3002 fn test_all_strategies_boundary_and_overlap() {
3003 use std::collections::HashMap;
3004
3005 let shapes = vec![
3007 Geometry2D::new("shape0")
3008 .with_polygon(vec![
3009 (0.0, 0.0),
3010 (180.0, 0.0),
3011 (195.0, 15.0),
3012 (200.0, 50.0),
3013 (200.0, 150.0),
3014 (195.0, 185.0),
3015 (180.0, 200.0),
3016 (20.0, 200.0),
3017 (5.0, 185.0),
3018 (0.0, 150.0),
3019 (0.0, 50.0),
3020 (5.0, 15.0),
3021 ])
3022 .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
3023 .with_quantity(2),
3024 Geometry2D::new("shape1_flange")
3025 .with_polygon(vec![
3026 (60.0, 0.0),
3027 (85.0, 7.0),
3028 (104.0, 25.0),
3029 (118.0, 50.0),
3030 (120.0, 60.0),
3031 (118.0, 70.0),
3032 (104.0, 95.0),
3033 (85.0, 113.0),
3034 (60.0, 120.0),
3035 (35.0, 113.0),
3036 (16.0, 95.0),
3037 (2.0, 70.0),
3038 (0.0, 60.0),
3039 (2.0, 50.0),
3040 (16.0, 25.0),
3041 (35.0, 7.0),
3042 ])
3043 .with_rotations_deg(vec![0.0, 45.0, 90.0, 135.0])
3044 .with_quantity(4),
3045 Geometry2D::new("shape2_lbracket")
3046 .with_polygon(vec![
3047 (0.0, 0.0),
3048 (80.0, 0.0),
3049 (80.0, 20.0),
3050 (20.0, 20.0),
3051 (20.0, 80.0),
3052 (0.0, 80.0),
3053 ])
3054 .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
3055 .with_quantity(6),
3056 Geometry2D::new("shape3_triangle")
3057 .with_polygon(vec![(0.0, 0.0), (70.0, 0.0), (0.0, 70.0)])
3058 .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
3059 .with_quantity(6),
3060 Geometry2D::new("shape4_rect")
3061 .with_polygon(vec![(0.0, 0.0), (120.0, 0.0), (120.0, 60.0), (0.0, 60.0)])
3062 .with_rotations_deg(vec![0.0, 90.0])
3063 .with_quantity(4),
3064 Geometry2D::new("shape5_hexagon")
3065 .with_polygon(vec![
3066 (15.0, 0.0),
3067 (45.0, 0.0),
3068 (60.0, 26.0),
3069 (45.0, 52.0),
3070 (15.0, 52.0),
3071 (0.0, 26.0),
3072 ])
3073 .with_rotations_deg(vec![0.0, 60.0, 120.0])
3074 .with_quantity(8),
3075 Geometry2D::new("shape6_tstiff")
3076 .with_polygon(vec![
3077 (0.0, 0.0),
3078 (90.0, 0.0),
3079 (90.0, 12.0),
3080 (55.0, 12.0),
3081 (55.0, 60.0),
3082 (35.0, 60.0),
3083 (35.0, 12.0),
3084 (0.0, 12.0),
3085 ])
3086 .with_rotations_deg(vec![0.0, 90.0, 180.0, 270.0])
3087 .with_quantity(4),
3088 Geometry2D::new("shape7_mount")
3089 .with_polygon(vec![
3090 (0.0, 10.0),
3091 (10.0, 0.0),
3092 (70.0, 0.0),
3093 (80.0, 10.0),
3094 (80.0, 70.0),
3095 (70.0, 80.0),
3096 (10.0, 80.0),
3097 (0.0, 70.0),
3098 ])
3099 .with_rotations_deg(vec![0.0, 90.0])
3100 .with_quantity(3),
3101 Geometry2D::new("shape8_gear")
3102 .with_polygon(vec![
3103 (50.0, 5.0),
3104 (65.0, 15.0),
3105 (77.0, 18.0),
3106 (80.0, 32.0),
3107 (95.0, 50.0),
3108 (80.0, 68.0),
3109 (77.0, 82.0),
3110 (65.0, 85.0),
3111 (50.0, 95.0),
3112 (35.0, 85.0),
3113 (23.0, 82.0),
3114 (20.0, 68.0),
3115 (5.0, 50.0),
3116 (20.0, 32.0),
3117 (23.0, 18.0),
3118 (35.0, 15.0),
3119 ])
3120 .with_rotations_deg(vec![0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0])
3121 .with_quantity(13),
3122 ];
3123
3124 let geom_map: HashMap<String, &Geometry2D> =
3126 shapes.iter().map(|g| (g.id().clone(), g)).collect();
3127
3128 let boundary = Boundary2D::rectangle(500.0, 500.0);
3129 let strip_width = 500.0;
3130
3131 let strategies = vec![
3133 Strategy::BottomLeftFill,
3134 Strategy::NfpGuided,
3135 Strategy::GeneticAlgorithm,
3136 Strategy::Brkga,
3137 Strategy::SimulatedAnnealing,
3138 Strategy::Gdrr,
3139 Strategy::Alns,
3140 ];
3141
3142 for strategy in strategies {
3143 println!("\n========== Testing {:?} ==========", strategy);
3144
3145 let config = Config::default()
3146 .with_strategy(strategy)
3147 .with_time_limit(30000); let nester = Nester2D::new(config);
3149
3150 let result = match nester.solve_multi_strip(&shapes, &boundary) {
3151 Ok(r) => r,
3152 Err(e) => {
3153 println!(" Strategy {:?} failed: {}", strategy, e);
3154 continue;
3155 }
3156 };
3157
3158 println!(
3159 " Placed {} pieces across {} strips",
3160 result.placements.len(),
3161 result.boundaries_used
3162 );
3163
3164 let mut boundary_violations = Vec::new();
3166 for p in &result.placements {
3167 let base_id = p.geometry_id.split('_').next().unwrap_or(&p.geometry_id);
3169 let full_id = if base_id.starts_with("shape") {
3170 shapes
3172 .iter()
3173 .find(|g| p.geometry_id.starts_with(g.id()))
3174 .map(|g| g.id().as_str())
3175 } else {
3176 geom_map.get(&p.geometry_id).map(|g| g.id().as_str())
3177 };
3178
3179 let geom = match full_id.and_then(|id| geom_map.get(id)) {
3180 Some(g) => *g,
3181 None => {
3182 match shapes.iter().find(|g| p.geometry_id.starts_with(g.id())) {
3184 Some(g) => g,
3185 None => {
3186 println!(
3187 " WARNING: Could not find geometry for {}",
3188 p.geometry_id
3189 );
3190 continue;
3191 }
3192 }
3193 }
3194 };
3195
3196 let origin_x = p.position[0];
3197 let origin_y = p.position[1];
3198 let rotation = p.rotation.first().copied().unwrap_or(0.0);
3199 let strip_idx = p.boundary_index;
3200
3201 let local_x = origin_x - (strip_idx as f64 * strip_width);
3203
3204 let (g_min, g_max) = geom.aabb_at_rotation(rotation);
3205
3206 let local_min_x = local_x + g_min[0];
3208 let local_max_x = local_x + g_max[0];
3209 let local_min_y = origin_y + g_min[1];
3210 let local_max_y = origin_y + g_max[1];
3211
3212 let tolerance = 0.1;
3214 if local_min_x < -tolerance
3215 || local_max_x > 500.0 + tolerance
3216 || local_min_y < -tolerance
3217 || local_max_y > 500.0 + tolerance
3218 {
3219 boundary_violations.push(format!(
3220 "{} in strip {}: bounds ({:.1}, {:.1}) to ({:.1}, {:.1})",
3221 p.geometry_id,
3222 strip_idx,
3223 local_min_x,
3224 local_min_y,
3225 local_max_x,
3226 local_max_y
3227 ));
3228 }
3229 }
3230
3231 if !boundary_violations.is_empty() {
3232 println!(" BOUNDARY VIOLATIONS ({}):", boundary_violations.len());
3233 for v in &boundary_violations {
3234 println!(" - {}", v);
3235 }
3236 }
3237
3238 let mut overlaps = Vec::new();
3240 let placements: Vec<_> = result.placements.iter().collect();
3241
3242 for i in 0..placements.len() {
3243 for j in (i + 1)..placements.len() {
3244 let p1 = placements[i];
3245 let p2 = placements[j];
3246
3247 if p1.boundary_index != p2.boundary_index {
3249 continue;
3250 }
3251
3252 let g1 = shapes.iter().find(|g| p1.geometry_id.starts_with(g.id()));
3254 let g2 = shapes.iter().find(|g| p2.geometry_id.starts_with(g.id()));
3255
3256 let (g1, g2) = match (g1, g2) {
3257 (Some(a), Some(b)) => (a, b),
3258 _ => continue,
3259 };
3260
3261 let strip_idx = p1.boundary_index;
3262 let local_x1 = p1.position[0] - (strip_idx as f64 * strip_width);
3263 let local_x2 = p2.position[0] - (strip_idx as f64 * strip_width);
3264
3265 let rot1 = p1.rotation.first().copied().unwrap_or(0.0);
3266 let rot2 = p2.rotation.first().copied().unwrap_or(0.0);
3267
3268 let (g1_min, g1_max) = g1.aabb_at_rotation(rot1);
3269 let (g2_min, g2_max) = g2.aabb_at_rotation(rot2);
3270
3271 let a_min = [local_x1 + g1_min[0], p1.position[1] + g1_min[1]];
3272 let a_max = [local_x1 + g1_max[0], p1.position[1] + g1_max[1]];
3273 let b_min = [local_x2 + g2_min[0], p2.position[1] + g2_min[1]];
3274 let b_max = [local_x2 + g2_max[0], p2.position[1] + g2_max[1]];
3275
3276 if aabbs_overlap(a_min, a_max, b_min, b_max, 1.0) {
3277 overlaps.push(format!(
3278 "{} and {} in strip {}",
3279 p1.geometry_id, p2.geometry_id, strip_idx
3280 ));
3281 }
3282 }
3283 }
3284
3285 if !overlaps.is_empty() {
3286 println!(" OVERLAPS ({}):", overlaps.len());
3287 for o in overlaps.iter().take(10) {
3288 println!(" - {}", o);
3289 }
3290 if overlaps.len() > 10 {
3291 println!(" ... and {} more", overlaps.len() - 10);
3292 }
3293 }
3294
3295 assert!(
3297 boundary_violations.is_empty(),
3298 "{:?}: Found {} boundary violations",
3299 strategy,
3300 boundary_violations.len()
3301 );
3302
3303 println!(" ✓ All placements within boundary");
3304 println!(" ✓ No AABB overlaps detected");
3305 }
3306 }
3307
3308 #[test]
3316 fn test_multi_strip_distributes_all_instances() {
3317 let geometries = vec![Geometry2D::rectangle("part", 100.0, 100.0).with_quantity(20)];
3318 let boundary = Boundary2D::rectangle(300.0, 300.0);
3319 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
3320 let nester = Nester2D::new(config);
3321
3322 let result = nester.solve_multi_strip(&geometries, &boundary).unwrap();
3323
3324 assert_eq!(
3326 result.placements.len(),
3327 20,
3328 "all 20 instances must be placed"
3329 );
3330 assert_eq!(
3331 result.boundaries_used, 3,
3332 "20 of 100x100 in 300x300 => 3 sheets"
3333 );
3334 assert!(
3335 result.unplaced.is_empty(),
3336 "nothing should be unplaced, got {:?}",
3337 result.unplaced
3338 );
3339 assert_eq!(result.total_requested, 20);
3341
3342 let mut seen = std::collections::HashSet::new();
3345 for p in &result.placements {
3346 assert!(
3347 seen.insert((p.geometry_id.clone(), p.instance)),
3348 "duplicate (id, instance) = ({}, {}) across sheets",
3349 p.geometry_id,
3350 p.instance
3351 );
3352 }
3353 let mut sheets: Vec<usize> = result.placements.iter().map(|p| p.boundary_index).collect();
3355 sheets.sort_unstable();
3356 sheets.dedup();
3357 assert_eq!(sheets, vec![0, 1, 2]);
3358 }
3359
3360 #[test]
3363 fn test_multi_strip_oversized_reported_unplaced() {
3364 let geometries = vec![
3365 Geometry2D::rectangle("ok", 50.0, 50.0).with_quantity(2),
3366 Geometry2D::rectangle("toobig", 400.0, 400.0).with_quantity(3),
3367 ];
3368 let boundary = Boundary2D::rectangle(300.0, 300.0);
3369 let config = Config::default().with_strategy(Strategy::BottomLeftFill);
3370 let nester = Nester2D::new(config);
3371
3372 let result = nester.solve_multi_strip(&geometries, &boundary).unwrap();
3373
3374 assert_eq!(result.total_requested, 5, "2 + 3 instances requested");
3375 assert_eq!(result.placements.len(), 2);
3377 assert!(
3378 result.unplaced.contains(&"toobig".to_string()),
3379 "oversized geometry must surface in unplaced, got {:?}",
3380 result.unplaced
3381 );
3382 }
3383}