Skip to main content

u_nesting_core/
result.rs

1//! Solve result representation.
2
3use crate::geometry::GeometryId;
4use crate::placement::{Placement, PlacementStats};
5
6#[cfg(feature = "serde")]
7use serde::{Deserialize, Serialize};
8
9/// Statistics for a single strip/boundary in multi-strip packing.
10#[derive(Debug, Clone, Default)]
11#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
12pub struct StripStats {
13    /// Index of the strip (0-based).
14    pub strip_index: usize,
15    /// Used length of the strip (max X extent of pieces).
16    pub used_length: f64,
17    /// Total area of pieces placed on this strip.
18    pub piece_area: f64,
19    /// Number of pieces placed on this strip.
20    pub piece_count: usize,
21    /// Strip width (height dimension for horizontal strips).
22    pub strip_width: f64,
23    /// Strip height (or max possible length).
24    pub strip_height: f64,
25}
26
27/// Result of a nesting or packing solve operation.
28#[derive(Debug, Clone)]
29#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
30pub struct SolveResult<S> {
31    /// List of placements for all successfully placed geometry instances.
32    pub placements: Vec<Placement<S>>,
33
34    /// Number of boundaries (bins) used.
35    pub boundaries_used: usize,
36
37    /// Utilization ratio (0.0 - 1.0).
38    /// Calculated as: total_geometry_measure / total_boundary_measure
39    pub utilization: f64,
40
41    /// IDs of geometries that could not be placed.
42    pub unplaced: Vec<GeometryId>,
43
44    /// Computation time in milliseconds.
45    pub computation_time_ms: u64,
46
47    /// Number of generations (for GA-based solvers).
48    pub generations: Option<u32>,
49
50    /// Number of iterations (for SA-based solvers).
51    pub iterations: Option<u64>,
52
53    /// Best fitness value achieved (for GA/SA-based solvers).
54    pub best_fitness: Option<f64>,
55
56    /// Fitness history over generations (for analysis).
57    pub fitness_history: Option<Vec<f64>>,
58
59    /// Strategy used for solving.
60    pub strategy: Option<String>,
61
62    /// Whether the solve was cancelled early.
63    pub cancelled: bool,
64
65    /// Whether the target utilization was reached.
66    pub target_reached: bool,
67
68    /// Per-strip statistics for multi-strip packing.
69    /// Contains used_length, piece_area, piece_count for each strip.
70    pub strip_stats: Vec<StripStats>,
71
72    /// Total area of all placed pieces.
73    pub total_piece_area: f64,
74
75    /// Total material area consumed (sum of strip_width × used_length for each strip).
76    pub total_material_used: f64,
77}
78
79impl<S> SolveResult<S> {
80    /// Creates a new empty result.
81    pub fn new() -> Self {
82        Self {
83            placements: Vec::new(),
84            boundaries_used: 0,
85            utilization: 0.0,
86            unplaced: Vec::new(),
87            computation_time_ms: 0,
88            generations: None,
89            iterations: None,
90            best_fitness: None,
91            fitness_history: None,
92            strategy: None,
93            cancelled: false,
94            target_reached: false,
95            strip_stats: Vec::new(),
96            total_piece_area: 0.0,
97            total_material_used: 0.0,
98        }
99    }
100
101    /// Returns true if all geometries were placed.
102    pub fn all_placed(&self) -> bool {
103        self.unplaced.is_empty()
104    }
105
106    /// Returns the number of placed geometry instances.
107    pub fn placed_count(&self) -> usize {
108        self.placements.len()
109    }
110
111    /// Returns the number of unplaced geometry types.
112    pub fn unplaced_count(&self) -> usize {
113        self.unplaced.len()
114    }
115
116    /// Returns true if the solve was successful (at least one placement).
117    pub fn is_successful(&self) -> bool {
118        !self.placements.is_empty()
119    }
120
121    /// Returns true if the solve completed within the time limit.
122    pub fn completed_normally(&self) -> bool {
123        !self.cancelled
124    }
125
126    /// Sets the strategy name.
127    pub fn with_strategy(mut self, strategy: impl Into<String>) -> Self {
128        self.strategy = Some(strategy.into());
129        self
130    }
131
132    /// Sets the generations count.
133    pub fn with_generations(mut self, generations: u32) -> Self {
134        self.generations = Some(generations);
135        self
136    }
137
138    /// Sets the best fitness.
139    pub fn with_best_fitness(mut self, fitness: f64) -> Self {
140        self.best_fitness = Some(fitness);
141        self
142    }
143
144    /// Sets the fitness history.
145    pub fn with_fitness_history(mut self, history: Vec<f64>) -> Self {
146        self.fitness_history = Some(history);
147        self
148    }
149
150    /// Removes duplicate entries from the unplaced list.
151    /// This is useful when multiple instances of the same geometry failed to place.
152    pub fn deduplicate_unplaced(&mut self) {
153        let mut seen = std::collections::HashSet::new();
154        self.unplaced.retain(|id| seen.insert(id.clone()));
155    }
156
157    /// Computes placement statistics.
158    pub fn placement_stats(&self) -> PlacementStats {
159        PlacementStats::from_placements(&self.placements)
160    }
161
162    /// Returns utilization as a percentage string.
163    pub fn utilization_percent(&self) -> String {
164        format!("{:.1}%", self.utilization * 100.0)
165    }
166
167    /// Merges placements from another result (for multi-bin scenarios).
168    pub fn merge(&mut self, other: SolveResult<S>, boundary_offset: usize) {
169        // Offset boundary indices
170        for mut placement in other.placements {
171            placement.boundary_index += boundary_offset;
172            self.placements.push(placement);
173        }
174
175        self.boundaries_used = self
176            .boundaries_used
177            .max(other.boundaries_used + boundary_offset);
178        self.unplaced.extend(other.unplaced);
179        self.computation_time_ms += other.computation_time_ms;
180
181        // Merge strip stats with offset
182        for mut strip_stat in other.strip_stats {
183            strip_stat.strip_index += boundary_offset;
184            self.strip_stats.push(strip_stat);
185        }
186        self.total_piece_area += other.total_piece_area;
187        self.total_material_used += other.total_material_used;
188
189        // Recalculate utilization
190        if self.total_material_used > 0.0 {
191            self.utilization = self.total_piece_area / self.total_material_used;
192        }
193    }
194
195    /// Sets strip statistics.
196    pub fn with_strip_stats(mut self, stats: Vec<StripStats>) -> Self {
197        self.strip_stats = stats;
198        self
199    }
200
201    /// Calculates and sets utilization from strip stats.
202    /// This is the accurate utilization based on actual material consumed.
203    pub fn calculate_utilization(&mut self) {
204        if self.strip_stats.is_empty() {
205            return;
206        }
207
208        self.total_piece_area = self.strip_stats.iter().map(|s| s.piece_area).sum();
209        self.total_material_used = self
210            .strip_stats
211            .iter()
212            .map(|s| s.strip_width * s.used_length)
213            .sum();
214
215        if self.total_material_used > 0.0 {
216            self.utilization = self.total_piece_area / self.total_material_used;
217        }
218    }
219}
220
221impl<S> Default for SolveResult<S> {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227/// Summary statistics for a solve result.
228#[derive(Debug, Clone)]
229#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
230pub struct SolveSummary {
231    /// Total geometries requested.
232    pub total_requested: usize,
233    /// Total geometries placed.
234    pub total_placed: usize,
235    /// Utilization percentage.
236    pub utilization_percent: f64,
237    /// Number of bins/boundaries used.
238    pub bins_used: usize,
239    /// Computation time in milliseconds.
240    pub time_ms: u64,
241    /// Strategy used.
242    pub strategy: String,
243}
244
245impl<S> From<&SolveResult<S>> for SolveSummary {
246    fn from(result: &SolveResult<S>) -> Self {
247        Self {
248            total_requested: result.placements.len() + result.unplaced.len(),
249            total_placed: result.placements.len(),
250            utilization_percent: result.utilization * 100.0,
251            bins_used: result.boundaries_used,
252            time_ms: result.computation_time_ms,
253            strategy: result
254                .strategy
255                .clone()
256                .unwrap_or_else(|| "unknown".to_string()),
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn test_result_new() {
267        let result: SolveResult<f64> = SolveResult::new();
268        assert!(result.placements.is_empty());
269        assert_eq!(result.utilization, 0.0);
270        assert!(result.all_placed());
271    }
272
273    #[test]
274    fn test_result_with_placements() {
275        let mut result: SolveResult<f64> = SolveResult::new();
276        result
277            .placements
278            .push(Placement::new_2d("test".to_string(), 0, 0.0, 0.0, 0.0));
279        result.utilization = 0.85;
280
281        assert_eq!(result.placed_count(), 1);
282        assert!(result.is_successful());
283        assert_eq!(result.utilization_percent(), "85.0%");
284    }
285
286    #[test]
287    fn test_result_with_unplaced() {
288        let mut result: SolveResult<f64> = SolveResult::new();
289        result.unplaced.push("G1".to_string());
290        result.unplaced.push("G2".to_string());
291
292        assert!(!result.all_placed());
293        assert_eq!(result.unplaced_count(), 2);
294    }
295
296    #[test]
297    fn test_solve_summary() {
298        let mut result: SolveResult<f64> = SolveResult::new();
299        result
300            .placements
301            .push(Placement::new_2d("test".to_string(), 0, 0.0, 0.0, 0.0));
302        result.utilization = 0.75;
303        result.boundaries_used = 1;
304        result.computation_time_ms = 100;
305        result.strategy = Some("GA".to_string());
306
307        let summary = SolveSummary::from(&result);
308        assert_eq!(summary.total_placed, 1);
309        assert_eq!(summary.utilization_percent, 75.0);
310        assert_eq!(summary.strategy, "GA");
311    }
312
313    #[test]
314    fn test_deduplicate_unplaced() {
315        let mut result: SolveResult<f64> = SolveResult::new();
316        // Simulate multiple instances of same geometry failing to place
317        result.unplaced.push("G1".to_string());
318        result.unplaced.push("G1".to_string());
319        result.unplaced.push("G2".to_string());
320        result.unplaced.push("G1".to_string());
321        result.unplaced.push("G2".to_string());
322
323        assert_eq!(result.unplaced.len(), 5);
324
325        result.deduplicate_unplaced();
326
327        assert_eq!(result.unplaced.len(), 2);
328        assert!(result.unplaced.contains(&"G1".to_string()));
329        assert!(result.unplaced.contains(&"G2".to_string()));
330    }
331}