Skip to main content

u_nesting_d2/
sa_nesting.rs

1//! Simulated Annealing-based 2D nesting optimization.
2//!
3//! This module provides Simulated Annealing based optimization for 2D nesting
4//! problems. SA uses neighborhood operators to explore the solution space
5//! and accepts worse solutions with a probability that decreases over time.
6//!
7//! # Neighborhood Operators
8//!
9//! - **Swap**: Exchange positions of two items in the sequence
10//! - **Relocate**: Move an item to a different position
11//! - **Inversion**: Reverse a segment of the sequence
12//! - **Rotation**: Change the rotation of an item
13
14use crate::boundary::Boundary2D;
15use crate::clamp_placement_to_boundary;
16use crate::geometry::Geometry2D;
17use crate::nfp::{
18    compute_ifp_with_margin_and_mirror, compute_nfp_mirrored, find_bottom_left_placement,
19    verify_no_overlap_mirrored, Nfp, PlacedGeometry,
20};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::Arc;
23use u_nesting_core::geometry::{Boundary, Geometry};
24use u_nesting_core::sa::{
25    NeighborhoodOperator, PermutationSolution, SaConfig, SaProblem, SaRunner, SaSolution,
26};
27use u_nesting_core::solver::Config;
28use u_nesting_core::{Placement, SolveResult};
29
30use crate::placement_utils::{expand_nfp, nesting_fitness, shrink_ifp, InstanceInfo};
31
32/// SA problem definition for 2D nesting.
33pub struct SaNestingProblem {
34    /// Input geometries.
35    geometries: Vec<Geometry2D>,
36    /// Boundary container.
37    boundary: Boundary2D,
38    /// Solver configuration.
39    config: Config,
40    /// Instance mapping (instance_id -> (geometry_idx, instance_num)).
41    instances: Vec<InstanceInfo>,
42    /// Available rotation angles per geometry.
43    rotation_angles: Vec<Vec<f64>>,
44    /// Maximum rotation options across all geometries.
45    max_rotation_options: usize,
46    /// Whether any geometry allows mirroring (`allow_flip` support) — gates
47    /// whether `MirrorFlip` is offered as a neighborhood operator, same
48    /// pattern as `max_rotation_options` gating `Rotation`.
49    any_allow_flip: bool,
50    /// Cancellation flag.
51    cancelled: Arc<AtomicBool>,
52}
53
54impl SaNestingProblem {
55    /// Creates a new SA nesting problem.
56    pub fn new(
57        geometries: Vec<Geometry2D>,
58        boundary: Boundary2D,
59        config: Config,
60        cancelled: Arc<AtomicBool>,
61    ) -> Self {
62        // Build instance mapping
63        let mut instances = Vec::new();
64        let mut rotation_angles = Vec::new();
65        let mut max_rotation_options = 1;
66        let mut any_allow_flip = false;
67
68        for (geom_idx, geom) in geometries.iter().enumerate() {
69            // Get rotation angles for this geometry
70            let angles = geom.rotations();
71            let angles = if angles.is_empty() { vec![0.0] } else { angles };
72            max_rotation_options = max_rotation_options.max(angles.len());
73            rotation_angles.push(angles);
74            any_allow_flip = any_allow_flip || geom.allow_flip();
75
76            // Create instances
77            for instance_num in 0..geom.quantity() {
78                instances.push(InstanceInfo {
79                    geometry_idx: geom_idx,
80                    instance_num,
81                });
82            }
83        }
84
85        Self {
86            geometries,
87            boundary,
88            config,
89            instances,
90            rotation_angles,
91            max_rotation_options,
92            any_allow_flip,
93            cancelled,
94        }
95    }
96
97    /// Returns the total number of instances.
98    pub fn num_instances(&self) -> usize {
99        self.instances.len()
100    }
101
102    /// Decodes a solution into placements using NFP-guided placement.
103    pub fn decode(&self, solution: &PermutationSolution) -> (Vec<Placement<f64>>, f64, usize) {
104        let n = self.instances.len();
105        if n == 0 || solution.sequence.is_empty() {
106            return (Vec::new(), 0.0, 0);
107        }
108
109        let mut placements = Vec::new();
110        let mut placed_geometries: Vec<PlacedGeometry> = Vec::new();
111        let mut total_placed_area = 0.0;
112        let mut placed_count = 0;
113
114        let margin = self.config.margin;
115        let spacing = self.config.spacing;
116
117        // Get boundary polygon with margin
118        let boundary_polygon = self.get_boundary_polygon_with_margin(margin);
119
120        // Sampling step for grid search
121        let sample_step = self.compute_sample_step();
122
123        // Place geometries in the solution order
124        for (seq_idx, &instance_idx) in solution.sequence.iter().enumerate() {
125            if self.cancelled.load(Ordering::Relaxed) {
126                break;
127            }
128
129            if instance_idx >= self.instances.len() {
130                continue;
131            }
132
133            let info = &self.instances[instance_idx];
134            let geom = &self.geometries[info.geometry_idx];
135
136            // Get rotation from solution
137            let rotation_idx = solution.rotations.get(seq_idx).copied().unwrap_or(0);
138            let num_rotations = self
139                .rotation_angles
140                .get(info.geometry_idx)
141                .map(|a| a.len())
142                .unwrap_or(1);
143
144            let rotation_angle = self
145                .rotation_angles
146                .get(info.geometry_idx)
147                .and_then(|angles| angles.get(rotation_idx % num_rotations))
148                .copied()
149                .unwrap_or(0.0);
150
151            // Mirror flag from solution (`allow_flip` support), masked
152            // against this instance's own geometry — see
153            // `PermutationSolution.mirrors` / `NeighborhoodOperator::MirrorFlip`.
154            let mirror =
155                solution.mirrors.get(seq_idx).copied().unwrap_or(false) && geom.allow_flip();
156
157            // Compute IFP for this geometry at this rotation
158            let ifp = match compute_ifp_with_margin_and_mirror(
159                &boundary_polygon,
160                geom,
161                rotation_angle,
162                0.0,
163                mirror,
164            ) {
165                Ok(ifp) => ifp,
166                Err(_) => continue,
167            };
168
169            if ifp.is_empty() {
170                continue;
171            }
172
173            // Compute NFPs with all placed geometries
174            let mut nfps: Vec<Nfp> = Vec::new();
175            for placed in &placed_geometries {
176                // Already-mirrored (if applicable) real-world polygon — do
177                // NOT mirror it again below, `mirror_stationary=false` always.
178                let placed_exterior = placed.translated_exterior();
179                let placed_geom = Geometry2D::new(format!("_placed_{}", placed.geometry.id()))
180                    .with_polygon(placed_exterior);
181
182                if let Ok(nfp) =
183                    compute_nfp_mirrored(&placed_geom, geom, rotation_angle, false, mirror)
184                {
185                    let expanded = expand_nfp(&nfp, spacing);
186                    nfps.push(expanded);
187                }
188            }
189
190            // Shrink IFP by spacing
191            let ifp_shrunk = shrink_ifp(&ifp, spacing);
192
193            // Find the bottom-left valid placement
194            // IFP returns positions where the geometry's origin should be placed.
195            // Clamp to ensure placement keeps geometry within boundary.
196            let nfp_refs: Vec<&Nfp> = nfps.iter().collect();
197            if let Some((x, y)) = find_bottom_left_placement(&ifp_shrunk, &nfp_refs, sample_step) {
198                // Clamp position to keep geometry within boundary
199                // (mirror-aware — an unmirrored AABB has the wrong local
200                // extents for a mirrored candidate, see `aabb_at_rotation_mirrored`).
201                let geom_aabb = geom.aabb_at_rotation_mirrored(rotation_angle, mirror);
202                let boundary_aabb = self.boundary.aabb();
203
204                if let Some((clamped_x, clamped_y)) =
205                    clamp_placement_to_boundary(x, y, geom_aabb, boundary_aabb)
206                {
207                    // Only verify overlap if clamping changed the position
208                    // The original NFP-found position is already collision-free by definition
209                    let was_clamped = (clamped_x - x).abs() > 1e-6 || (clamped_y - y).abs() > 1e-6;
210                    if was_clamped {
211                        // Verify no actual polygon overlap using SAT
212                        if !verify_no_overlap_mirrored(
213                            geom,
214                            (clamped_x, clamped_y),
215                            rotation_angle,
216                            mirror,
217                            &placed_geometries,
218                        ) {
219                            continue; // Skip - clamped position would cause overlap
220                        }
221                    }
222
223                    let placement = Placement::new_2d(
224                        geom.id().clone(),
225                        info.instance_num,
226                        clamped_x,
227                        clamped_y,
228                        rotation_angle,
229                    )
230                    .with_mirrored(mirror);
231
232                    placements.push(placement);
233                    placed_geometries.push(
234                        PlacedGeometry::new(geom.clone(), (clamped_x, clamped_y), rotation_angle)
235                            .with_mirrored(mirror),
236                    );
237                    total_placed_area += geom.measure();
238                    placed_count += 1;
239                }
240            }
241        }
242
243        let utilization = total_placed_area / self.boundary.measure();
244        (placements, utilization, placed_count)
245    }
246
247    /// Gets the boundary polygon with margin applied.
248    fn get_boundary_polygon_with_margin(&self, margin: f64) -> Vec<(f64, f64)> {
249        let (b_min, b_max) = self.boundary.aabb();
250        vec![
251            (b_min[0] + margin, b_min[1] + margin),
252            (b_max[0] - margin, b_min[1] + margin),
253            (b_max[0] - margin, b_max[1] - margin),
254            (b_min[0] + margin, b_max[1] - margin),
255        ]
256    }
257
258    /// Computes an adaptive sample step based on geometry sizes.
259    fn compute_sample_step(&self) -> f64 {
260        if self.geometries.is_empty() {
261            return 1.0;
262        }
263
264        let mut min_dim = f64::INFINITY;
265        for geom in &self.geometries {
266            let (g_min, g_max) = geom.aabb();
267            let width = g_max[0] - g_min[0];
268            let height = g_max[1] - g_min[1];
269            min_dim = min_dim.min(width).min(height);
270        }
271
272        (min_dim / 4.0).clamp(0.5, 10.0)
273    }
274}
275
276impl SaProblem for SaNestingProblem {
277    type Solution = PermutationSolution;
278
279    fn initial_solution<R: rand::Rng>(&self, rng: &mut R) -> Self::Solution {
280        PermutationSolution::random(self.instances.len(), self.max_rotation_options, rng)
281    }
282
283    fn neighbor<R: rand::Rng>(
284        &self,
285        solution: &Self::Solution,
286        operator: NeighborhoodOperator,
287        rng: &mut R,
288    ) -> Self::Solution {
289        match operator {
290            NeighborhoodOperator::Swap => solution.apply_swap(rng),
291            NeighborhoodOperator::Relocate => solution.apply_relocate(rng),
292            NeighborhoodOperator::Inversion => solution.apply_inversion(rng),
293            NeighborhoodOperator::Rotation => solution.apply_rotation(rng),
294            NeighborhoodOperator::Chain => solution.apply_chain(rng),
295            NeighborhoodOperator::MirrorFlip => solution.apply_mirror_flip(rng),
296        }
297    }
298
299    fn evaluate(&self, solution: &mut Self::Solution) {
300        let (_, utilization, placed_count) = self.decode(solution);
301        let fitness = nesting_fitness(placed_count, self.instances.len(), utilization);
302        solution.set_objective(fitness);
303    }
304
305    fn available_operators(&self) -> Vec<NeighborhoodOperator> {
306        let mut ops = vec![
307            NeighborhoodOperator::Swap,
308            NeighborhoodOperator::Relocate,
309            NeighborhoodOperator::Inversion,
310            NeighborhoodOperator::Chain,
311        ];
312        if self.max_rotation_options > 1 {
313            ops.push(NeighborhoodOperator::Rotation);
314        }
315        if self.any_allow_flip {
316            ops.push(NeighborhoodOperator::MirrorFlip);
317        }
318        ops
319    }
320
321    fn on_temperature_change(
322        &self,
323        temperature: f64,
324        iteration: u64,
325        best: &Self::Solution,
326        _current: &Self::Solution,
327    ) {
328        log::debug!(
329            "SA Iteration {}: temp={:.4}, best_fitness={:.4}",
330            iteration,
331            temperature,
332            best.objective()
333        );
334    }
335}
336
337/// Runs SA-based nesting optimization.
338pub fn run_sa_nesting(
339    geometries: &[Geometry2D],
340    boundary: &Boundary2D,
341    config: &Config,
342    sa_config: SaConfig,
343    cancelled: Arc<AtomicBool>,
344) -> SolveResult<f64> {
345    let problem = SaNestingProblem::new(
346        geometries.to_vec(),
347        boundary.clone(),
348        config.clone(),
349        cancelled.clone(),
350    );
351
352    let runner = SaRunner::new(sa_config, problem);
353
354    // Set cancellation (thread-based polling, not available on WASM)
355    #[cfg(not(target_arch = "wasm32"))]
356    {
357        let cancel_handle = runner.cancel_handle();
358        let cancelled_clone = cancelled.clone();
359        std::thread::spawn(move || {
360            while !cancelled_clone.load(Ordering::Relaxed) {
361                std::thread::sleep(std::time::Duration::from_millis(100));
362            }
363            cancel_handle.store(true, Ordering::Relaxed);
364        });
365    }
366
367    // Seed the RNG for reproducibility when `config.seed` is set; otherwise use
368    // system entropy (non-deterministic).
369    let sa_result = match config.seed {
370        Some(seed) => {
371            use rand::SeedableRng;
372            runner.run_with_rng(&mut rand::rngs::StdRng::seed_from_u64(seed))
373        }
374        None => runner.run(),
375    };
376
377    // Decode the best solution to get final placements
378    let problem = SaNestingProblem::new(
379        geometries.to_vec(),
380        boundary.clone(),
381        config.clone(),
382        Arc::new(AtomicBool::new(false)),
383    );
384
385    let (placements, utilization, _placed_count) = problem.decode(&sa_result.best);
386
387    // Build unplaced list
388    let mut unplaced = Vec::new();
389    let mut placed_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
390    for p in &placements {
391        placed_ids.insert(p.geometry_id.clone());
392    }
393    for geom in geometries {
394        if !placed_ids.contains(geom.id()) {
395            unplaced.push(geom.id().clone());
396        }
397    }
398
399    let mut result = SolveResult::new();
400    result.placements = placements;
401    result.unplaced = unplaced;
402    result.boundaries_used = 1;
403    result.utilization = utilization;
404    result.computation_time_ms = sa_result.elapsed.as_millis() as u64;
405    result.iterations = Some(sa_result.iterations);
406    result.best_fitness = Some(sa_result.best.objective());
407    result.fitness_history = Some(sa_result.history);
408    result.strategy = Some("SimulatedAnnealing".to_string());
409    result.cancelled = cancelled.load(Ordering::Relaxed);
410    result.target_reached = sa_result.target_reached;
411
412    result
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn test_sa_nesting_basic() {
421        let geometries = vec![
422            Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2),
423            Geometry2D::rectangle("R2", 15.0, 15.0).with_quantity(2),
424        ];
425
426        let boundary = Boundary2D::rectangle(100.0, 50.0);
427        let config = Config::default();
428        let sa_config = SaConfig::default()
429            .with_initial_temp(100.0)
430            .with_final_temp(0.1)
431            .with_cooling_rate(0.9)
432            .with_iterations_per_temp(20)
433            .with_max_iterations(500);
434
435        let result = run_sa_nesting(
436            &geometries,
437            &boundary,
438            &config,
439            sa_config,
440            Arc::new(AtomicBool::new(false)),
441        );
442
443        assert!(result.utilization > 0.0);
444        assert!(!result.placements.is_empty());
445        assert_eq!(result.strategy, Some("SimulatedAnnealing".to_string()));
446    }
447
448    #[test]
449    fn test_sa_nesting_all_placed() {
450        let geometries = vec![Geometry2D::rectangle("R1", 20.0, 20.0).with_quantity(4)];
451
452        let boundary = Boundary2D::rectangle(100.0, 100.0);
453        let config = Config::default();
454        let sa_config = SaConfig::default()
455            .with_initial_temp(100.0)
456            .with_final_temp(0.1)
457            .with_max_iterations(1000);
458
459        let result = run_sa_nesting(
460            &geometries,
461            &boundary,
462            &config,
463            sa_config,
464            Arc::new(AtomicBool::new(false)),
465        );
466
467        // All 4 pieces should fit easily
468        assert_eq!(result.placements.len(), 4);
469        assert!(result.unplaced.is_empty());
470    }
471
472    #[test]
473    fn test_sa_nesting_with_rotation() {
474        let geometries = vec![Geometry2D::rectangle("R1", 30.0, 10.0)
475            .with_quantity(3)
476            .with_rotations(vec![0.0, 90.0])];
477
478        let boundary = Boundary2D::rectangle(50.0, 50.0);
479        let config = Config::default();
480        let sa_config = SaConfig::default()
481            .with_initial_temp(100.0)
482            .with_final_temp(0.1)
483            .with_max_iterations(500);
484
485        let result = run_sa_nesting(
486            &geometries,
487            &boundary,
488            &config,
489            sa_config,
490            Arc::new(AtomicBool::new(false)),
491        );
492
493        assert!(result.utilization > 0.0);
494        assert!(!result.placements.is_empty());
495    }
496
497    #[test]
498    fn test_sa_problem_decode() {
499        let geometries = vec![Geometry2D::rectangle("R1", 20.0, 10.0).with_quantity(2)];
500
501        let boundary = Boundary2D::rectangle(100.0, 50.0);
502        let config = Config::default();
503        let cancelled = Arc::new(AtomicBool::new(false));
504
505        let problem = SaNestingProblem::new(geometries, boundary, config, cancelled);
506
507        assert_eq!(problem.num_instances(), 2);
508
509        // Create a random solution and decode
510        let mut rng = rand::rng();
511        let solution = PermutationSolution::random(problem.num_instances(), 1, &mut rng);
512        let (placements, utilization, placed_count) = problem.decode(&solution);
513
514        // Should place at least one item
515        assert!(placed_count >= 1);
516        assert_eq!(placements.len(), placed_count);
517        if placed_count > 0 {
518            assert!(utilization > 0.0);
519        }
520    }
521
522    #[test]
523    fn test_permutation_solution_mirrors_gene_present() {
524        let mut rng = rand::rng();
525        let solution = PermutationSolution::random(10, 4, &mut rng);
526        assert_eq!(solution.mirrors.len(), 10);
527
528        let fixed = PermutationSolution::new(10, 4);
529        assert_eq!(fixed.mirrors, vec![false; 10]);
530    }
531
532    #[test]
533    fn test_apply_mirror_flip_flips_bit() {
534        let mut rng = rand::rng();
535        let mut single = PermutationSolution::new(1, 1);
536        assert!(!single.mirrors[0]);
537
538        single = single.apply_mirror_flip(&mut rng);
539        assert!(single.mirrors[0]);
540        single = single.apply_mirror_flip(&mut rng);
541        assert!(!single.mirrors[0]);
542    }
543
544    #[test]
545    fn test_available_operators_includes_mirror_flip_only_when_allowed() {
546        let boundary = Boundary2D::rectangle(65.0, 45.0);
547        let cancelled = Arc::new(AtomicBool::new(false));
548
549        let plain = vec![Geometry2D::rectangle("R", 10.0, 10.0).with_quantity(1)];
550        let problem = SaNestingProblem::new(
551            plain,
552            boundary.clone(),
553            Config::default(),
554            cancelled.clone(),
555        );
556        assert!(!problem
557            .available_operators()
558            .contains(&NeighborhoodOperator::MirrorFlip));
559
560        let flippable = vec![Geometry2D::rectangle("R", 10.0, 10.0)
561            .with_flip(true)
562            .with_quantity(1)];
563        let problem = SaNestingProblem::new(flippable, boundary, Config::default(), cancelled);
564        assert!(problem
565            .available_operators()
566            .contains(&NeighborhoodOperator::MirrorFlip));
567    }
568
569    /// Chiral L-shape — see `nfp.rs`'s `chiral_l` fixture for why this
570    /// specific shape (asymmetric width/height/notch, no reflection symmetry).
571    fn chiral_l(id: &str) -> Geometry2D {
572        Geometry2D::l_shape(id, 30.0, 20.0, 20.0, 10.0)
573    }
574
575    fn polygons_overlap(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
576        for i in 0..a.len() {
577            let (a1, a2) = (a[i], a[(i + 1) % a.len()]);
578            for j in 0..b.len() {
579                let (b1, b2) = (b[j], b[(j + 1) % b.len()]);
580                if crate::polygon_ops::segments_intersect(a1, a2, b1, b2) {
581                    return true;
582                }
583            }
584        }
585        false
586    }
587
588    /// Phase 3 (`allow_flip`/mirroring), SA strategy. Same rationale as
589    /// Phase 2's GA test: `SaNestingProblem::decode()` calls no
590    /// `.validate()` (only `solve()`'s centralized gate does), so calling it
591    /// directly exercises the mirror flag deterministically — useful even
592    /// now that the public gate is open (Phase 4), since SA's own public
593    /// path is randomized.
594    #[test]
595    fn test_sa_decode_mirror_no_overlap() {
596        let geometries = vec![chiral_l("L").with_flip(true).with_quantity(2)];
597        let boundary = Boundary2D::rectangle(65.0, 45.0);
598        let config = Config::default().with_spacing(1.0);
599        let problem = SaNestingProblem::new(
600            geometries.clone(),
601            boundary,
602            config,
603            Arc::new(AtomicBool::new(false)),
604        );
605
606        let mut solution = PermutationSolution::new(2, 1);
607        solution.mirrors = vec![false, true];
608
609        let (placements, utilization, placed_count) = problem.decode(&solution);
610
611        assert_eq!(
612            placed_count, 2,
613            "both instances should fit in this boundary"
614        );
615        assert_eq!(placements.len(), 2);
616        assert!(utilization > 0.0);
617        assert!(!placements[0].mirrored);
618        assert!(
619            placements[1].mirrored,
620            "instance 1's mirror gene was true and allow_flip is set — decode() must honor it"
621        );
622
623        let poly0 = PlacedGeometry::new(
624            geometries[0].clone(),
625            (placements[0].x(), placements[0].y()),
626            placements[0].angle(),
627        )
628        .with_mirrored(placements[0].mirrored)
629        .translated_exterior();
630        let poly1 = PlacedGeometry::new(
631            geometries[0].clone(),
632            (placements[1].x(), placements[1].y()),
633            placements[1].angle(),
634        )
635        .with_mirrored(placements[1].mirrored)
636        .translated_exterior();
637        assert!(
638            !polygons_overlap(&poly0, &poly1),
639            "unmirrored instance 0 and mirrored instance 1 must not overlap"
640        );
641    }
642
643    #[test]
644    fn test_sa_decode_mirror_ignored_without_allow_flip() {
645        let geometries = vec![chiral_l("L").with_quantity(1)];
646        let boundary = Boundary2D::rectangle(65.0, 45.0);
647        let problem = SaNestingProblem::new(
648            geometries,
649            boundary,
650            Config::default(),
651            Arc::new(AtomicBool::new(false)),
652        );
653
654        let mut solution = PermutationSolution::new(1, 1);
655        solution.mirrors = vec![true];
656
657        let (placements, _utilization, placed_count) = problem.decode(&solution);
658        assert_eq!(placed_count, 1);
659        assert!(
660            !placements[0].mirrored,
661            "allow_flip=false must suppress the mirror gene"
662        );
663    }
664}