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