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    /// Total number of geometry **instances** requested (Σ of every geometry's
79    /// quantity). This is the instance-level denominator: the count of unplaced
80    /// instances is `total_requested - placements.len()`, since `placements` is
81    /// instance-level while `unplaced` is deduplicated to unique geometry IDs.
82    /// Set once at the top-level `solve`/`solve_with_progress` entry point.
83    pub total_requested: usize,
84}
85
86impl<S> SolveResult<S> {
87    /// Creates a new empty result.
88    pub fn new() -> Self {
89        Self {
90            placements: Vec::new(),
91            boundaries_used: 0,
92            utilization: 0.0,
93            unplaced: Vec::new(),
94            computation_time_ms: 0,
95            generations: None,
96            iterations: None,
97            best_fitness: None,
98            fitness_history: None,
99            strategy: None,
100            cancelled: false,
101            target_reached: false,
102            strip_stats: Vec::new(),
103            total_piece_area: 0.0,
104            total_material_used: 0.0,
105            total_requested: 0,
106        }
107    }
108
109    /// Returns true if all geometries were placed.
110    pub fn all_placed(&self) -> bool {
111        self.unplaced.is_empty()
112    }
113
114    /// Returns the number of placed geometry instances.
115    pub fn placed_count(&self) -> usize {
116        self.placements.len()
117    }
118
119    /// Returns the number of unplaced geometry types.
120    pub fn unplaced_count(&self) -> usize {
121        self.unplaced.len()
122    }
123
124    /// Returns true if the solve was successful (at least one placement).
125    pub fn is_successful(&self) -> bool {
126        !self.placements.is_empty()
127    }
128
129    /// Returns true if the solve completed within the time limit.
130    pub fn completed_normally(&self) -> bool {
131        !self.cancelled
132    }
133
134    /// Sets the strategy name.
135    pub fn with_strategy(mut self, strategy: impl Into<String>) -> Self {
136        self.strategy = Some(strategy.into());
137        self
138    }
139
140    /// Sets the generations count.
141    pub fn with_generations(mut self, generations: u32) -> Self {
142        self.generations = Some(generations);
143        self
144    }
145
146    /// Sets the best fitness.
147    pub fn with_best_fitness(mut self, fitness: f64) -> Self {
148        self.best_fitness = Some(fitness);
149        self
150    }
151
152    /// Sets the fitness history.
153    pub fn with_fitness_history(mut self, history: Vec<f64>) -> Self {
154        self.fitness_history = Some(history);
155        self
156    }
157
158    /// Removes duplicate entries from the unplaced list.
159    /// This is useful when multiple instances of the same geometry failed to place.
160    pub fn deduplicate_unplaced(&mut self) {
161        let mut seen = std::collections::HashSet::new();
162        self.unplaced.retain(|id| seen.insert(id.clone()));
163    }
164
165    /// Computes placement statistics.
166    pub fn placement_stats(&self) -> PlacementStats {
167        PlacementStats::from_placements(&self.placements)
168    }
169
170    /// Returns utilization as a percentage string.
171    pub fn utilization_percent(&self) -> String {
172        format!("{:.1}%", self.utilization * 100.0)
173    }
174
175    /// Merges placements from another result (for multi-bin scenarios).
176    pub fn merge(&mut self, other: SolveResult<S>, boundary_offset: usize) {
177        // Offset boundary indices
178        for mut placement in other.placements {
179            placement.boundary_index += boundary_offset;
180            self.placements.push(placement);
181        }
182
183        self.boundaries_used = self
184            .boundaries_used
185            .max(other.boundaries_used + boundary_offset);
186        self.unplaced.extend(other.unplaced);
187        self.computation_time_ms += other.computation_time_ms;
188
189        // Merge strip stats with offset
190        for mut strip_stat in other.strip_stats {
191            strip_stat.strip_index += boundary_offset;
192            self.strip_stats.push(strip_stat);
193        }
194        self.total_piece_area += other.total_piece_area;
195        self.total_material_used += other.total_material_used;
196        self.total_requested += other.total_requested;
197
198        // Recalculate utilization
199        if self.total_material_used > 0.0 {
200            self.utilization = self.total_piece_area / self.total_material_used;
201        }
202    }
203
204    /// Sets strip statistics.
205    pub fn with_strip_stats(mut self, stats: Vec<StripStats>) -> Self {
206        self.strip_stats = stats;
207        self
208    }
209
210    /// Calculates and sets utilization from strip stats.
211    /// This is the accurate utilization based on actual material consumed.
212    pub fn calculate_utilization(&mut self) {
213        if self.strip_stats.is_empty() {
214            return;
215        }
216
217        self.total_piece_area = self.strip_stats.iter().map(|s| s.piece_area).sum();
218        self.total_material_used = self
219            .strip_stats
220            .iter()
221            .map(|s| s.strip_width * s.used_length)
222            .sum();
223
224        if self.total_material_used > 0.0 {
225            self.utilization = self.total_piece_area / self.total_material_used;
226        }
227    }
228}
229
230impl<S> Default for SolveResult<S> {
231    fn default() -> Self {
232        Self::new()
233    }
234}
235
236impl SolveResult<f64> {
237    /// Rewrites placement x-coordinates from the global multi-strip frame
238    /// (sheets laid side-by-side: `x ≈ boundary_index * extent`) into the
239    /// per-boundary **local** frame, so each placement's x is relative to the
240    /// origin of its own sheet (`boundary_index` selects the sheet).
241    ///
242    /// `solve_multi_strip` emits global strip coordinates (the strip-packing
243    /// representation its benchmark callers rely on). Binding/wire consumers that
244    /// render per-sheet panels want sheet-local coordinates instead; this is the
245    /// single shared transform they apply at the response boundary.
246    ///
247    /// `extent` is the boundary's x-axis extent (`b_max[0] - b_min[0]`), identical
248    /// to the `strip_width` used during solving. After this call every placement's
249    /// x lies in `[0, extent)` (modulo spacing/margin) within its sheet.
250    pub fn to_boundary_local(&mut self, extent: f64) {
251        for placement in &mut self.placements {
252            if let Some(x) = placement.position.first_mut() {
253                *x -= placement.boundary_index as f64 * extent;
254            }
255        }
256    }
257}
258
259/// Summary statistics for a solve result.
260#[derive(Debug, Clone)]
261#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
262pub struct SolveSummary {
263    /// Total geometries requested.
264    pub total_requested: usize,
265    /// Total geometries placed.
266    pub total_placed: usize,
267    /// Utilization percentage.
268    pub utilization_percent: f64,
269    /// Number of bins/boundaries used.
270    pub bins_used: usize,
271    /// Computation time in milliseconds.
272    pub time_ms: u64,
273    /// Strategy used.
274    pub strategy: String,
275}
276
277impl<S> From<&SolveResult<S>> for SolveSummary {
278    fn from(result: &SolveResult<S>) -> Self {
279        Self {
280            // `result.unplaced` is deduplicated to unique geometry IDs, so
281            // `placements.len() + unplaced.len()` undercounts whenever a
282            // multi-quantity geometry is partially/fully unplaced. Use the
283            // authoritative instance-level total recorded at solve time.
284            total_requested: result.total_requested,
285            total_placed: result.placements.len(),
286            utilization_percent: result.utilization * 100.0,
287            bins_used: result.boundaries_used,
288            time_ms: result.computation_time_ms,
289            strategy: result
290                .strategy
291                .clone()
292                .unwrap_or_else(|| "unknown".to_string()),
293        }
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_result_new() {
303        let result: SolveResult<f64> = SolveResult::new();
304        assert!(result.placements.is_empty());
305        assert_eq!(result.utilization, 0.0);
306        assert!(result.all_placed());
307    }
308
309    #[test]
310    fn test_result_with_placements() {
311        let mut result: SolveResult<f64> = SolveResult::new();
312        result
313            .placements
314            .push(Placement::new_2d("test".to_string(), 0, 0.0, 0.0, 0.0));
315        result.utilization = 0.85;
316
317        assert_eq!(result.placed_count(), 1);
318        assert!(result.is_successful());
319        assert_eq!(result.utilization_percent(), "85.0%");
320    }
321
322    #[test]
323    fn test_result_with_unplaced() {
324        let mut result: SolveResult<f64> = SolveResult::new();
325        result.unplaced.push("G1".to_string());
326        result.unplaced.push("G2".to_string());
327
328        assert!(!result.all_placed());
329        assert_eq!(result.unplaced_count(), 2);
330    }
331
332    #[test]
333    fn test_solve_summary() {
334        let mut result: SolveResult<f64> = SolveResult::new();
335        result
336            .placements
337            .push(Placement::new_2d("test".to_string(), 0, 0.0, 0.0, 0.0));
338        result.utilization = 0.75;
339        result.boundaries_used = 1;
340        result.computation_time_ms = 100;
341        result.strategy = Some("GA".to_string());
342
343        let summary = SolveSummary::from(&result);
344        assert_eq!(summary.total_placed, 1);
345        assert_eq!(summary.utilization_percent, 75.0);
346        assert_eq!(summary.strategy, "GA");
347    }
348
349    #[test]
350    fn test_solve_summary_total_requested_is_instance_level() {
351        // Reproduces the reported defect: one geometry with quantity 5, of which
352        // only 1 instance is placed. `unplaced` is deduplicated to the single
353        // failing geometry ID. The old summary formula
354        // (placements.len() + unplaced.len() = 1 + 1 = 2) undercounts the true
355        // request total. With the authoritative `total_requested`, it is 5.
356        let mut result: SolveResult<f64> = SolveResult::new();
357        result
358            .placements
359            .push(Placement::new_2d("A".to_string(), 0, 0.0, 0.0, 0.0));
360        result.unplaced.push("A".to_string()); // deduplicated single ID
361        result.total_requested = 5;
362
363        let summary = SolveSummary::from(&result);
364        assert_eq!(summary.total_requested, 5);
365        assert_eq!(summary.total_placed, 1);
366        // Instance-level unplaced count, recovered by consumers:
367        assert_eq!(summary.total_requested - summary.total_placed, 4);
368    }
369
370    #[test]
371    fn test_merge_sums_total_requested() {
372        let mut a: SolveResult<f64> = SolveResult::new();
373        a.total_requested = 3;
374        let mut b: SolveResult<f64> = SolveResult::new();
375        b.total_requested = 4;
376        a.merge(b, 0);
377        assert_eq!(a.total_requested, 7);
378    }
379
380    #[test]
381    fn test_deduplicate_unplaced() {
382        let mut result: SolveResult<f64> = SolveResult::new();
383        // Simulate multiple instances of same geometry failing to place
384        result.unplaced.push("G1".to_string());
385        result.unplaced.push("G1".to_string());
386        result.unplaced.push("G2".to_string());
387        result.unplaced.push("G1".to_string());
388        result.unplaced.push("G2".to_string());
389
390        assert_eq!(result.unplaced.len(), 5);
391
392        result.deduplicate_unplaced();
393
394        assert_eq!(result.unplaced.len(), 2);
395        assert!(result.unplaced.contains(&"G1".to_string()));
396        assert!(result.unplaced.contains(&"G2".to_string()));
397    }
398
399    #[test]
400    fn test_to_boundary_local_subtracts_per_sheet_offset() {
401        // Three placements at the same sheet-local x=10, on sheets 0/1/2, in a global
402        // strip frame (x = local + sheet * 100). Localizing must recover x=10 on each.
403        let extent = 100.0;
404        let mut result: SolveResult<f64> = SolveResult::new();
405        for sheet in 0..3 {
406            result.placements.push(
407                Placement::new_2d(
408                    "p".to_string(),
409                    sheet,
410                    10.0 + sheet as f64 * extent,
411                    5.0,
412                    0.0,
413                )
414                .with_boundary(sheet),
415            );
416        }
417
418        result.to_boundary_local(extent);
419
420        for p in &result.placements {
421            assert_eq!(p.position[0], 10.0, "x must be sheet-local after transform");
422            assert_eq!(p.position[1], 5.0, "y is untouched");
423        }
424    }
425}