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    /// The best of a search's result (`self`) and `baselines` -- layouts a
168    /// simple deterministic fill produced for the same request -- where `beats`
169    /// says whether its first argument is strictly better than its second.
170    ///
171    /// A search can stop, on a short budget, at a layout the simple fill would
172    /// have beaten; flooring it here means a search never returns less. A tie
173    /// keeps the search's layout. When a baseline wins, its placements are
174    /// returned with the search's provenance -- strategy, generations,
175    /// iterations, fitness history, target flag -- because the search did run.
176    pub fn floored_at(
177        self,
178        baselines: impl IntoIterator<Item = Self>,
179        beats: impl Fn(&Self, &Self) -> bool,
180    ) -> Self {
181        let mut best: Option<Self> = None;
182        for baseline in baselines {
183            if beats(&baseline, best.as_ref().unwrap_or(&self)) {
184                best = Some(baseline);
185            }
186        }
187        match best {
188            Some(mut floored) => {
189                floored.strategy = self.strategy;
190                floored.generations = self.generations;
191                floored.iterations = self.iterations;
192                floored.best_fitness = self.best_fitness;
193                floored.fitness_history = self.fitness_history;
194                floored.target_reached = self.target_reached;
195                floored.cancelled = self.cancelled;
196                floored
197            }
198            None => self,
199        }
200    }
201
202    /// Removes duplicate entries from the unplaced list.
203    /// This is useful when multiple instances of the same geometry failed to place.
204    pub fn deduplicate_unplaced(&mut self) {
205        let mut seen = std::collections::HashSet::new();
206        self.unplaced.retain(|id| seen.insert(id.clone()));
207    }
208
209    /// Computes placement statistics.
210    pub fn placement_stats(&self) -> PlacementStats {
211        PlacementStats::from_placements(&self.placements)
212    }
213
214    /// Returns utilization as a percentage string.
215    pub fn utilization_percent(&self) -> String {
216        format!("{:.1}%", self.utilization * 100.0)
217    }
218
219    /// Merges placements from another result (for multi-bin scenarios).
220    pub fn merge(&mut self, other: SolveResult<S>, boundary_offset: usize) {
221        // Offset boundary indices
222        for mut placement in other.placements {
223            placement.boundary_index += boundary_offset;
224            self.placements.push(placement);
225        }
226
227        self.boundaries_used = self
228            .boundaries_used
229            .max(other.boundaries_used + boundary_offset);
230        self.unplaced.extend(other.unplaced);
231        self.computation_time_ms += other.computation_time_ms;
232
233        // Merge strip stats with offset
234        for mut strip_stat in other.strip_stats {
235            strip_stat.strip_index += boundary_offset;
236            self.strip_stats.push(strip_stat);
237        }
238        self.total_piece_area += other.total_piece_area;
239        self.total_material_used += other.total_material_used;
240        self.total_requested += other.total_requested;
241        // Used footprints live in per-sheet local frames, so a union box is
242        // ill-defined across sheets; report the element-wise max extent seen.
243        self.used_bounding_box = [
244            self.used_bounding_box[0].max(other.used_bounding_box[0]),
245            self.used_bounding_box[1].max(other.used_bounding_box[1]),
246        ];
247
248        // Recalculate utilization
249        if self.total_material_used > 0.0 {
250            self.utilization = self.total_piece_area / self.total_material_used;
251        }
252    }
253
254    /// Sets strip statistics.
255    pub fn with_strip_stats(mut self, stats: Vec<StripStats>) -> Self {
256        self.strip_stats = stats;
257        self
258    }
259
260    /// Calculates and sets utilization from strip stats.
261    /// This is the accurate utilization based on actual material consumed.
262    pub fn calculate_utilization(&mut self) {
263        if self.strip_stats.is_empty() {
264            return;
265        }
266
267        self.total_piece_area = self.strip_stats.iter().map(|s| s.piece_area).sum();
268        self.total_material_used = self
269            .strip_stats
270            .iter()
271            .map(|s| s.strip_width * s.used_length)
272            .sum();
273
274        if self.total_material_used > 0.0 {
275            self.utilization = self.total_piece_area / self.total_material_used;
276        }
277    }
278}
279
280impl<S> Default for SolveResult<S> {
281    fn default() -> Self {
282        Self::new()
283    }
284}
285
286impl SolveResult<f64> {
287    /// Rewrites placement x-coordinates from the global multi-strip frame
288    /// (sheets laid side-by-side: `x ≈ boundary_index * extent`) into the
289    /// per-boundary **local** frame, so each placement's x is relative to the
290    /// origin of its own sheet (`boundary_index` selects the sheet).
291    ///
292    /// `solve_multi_strip` emits global strip coordinates (the strip-packing
293    /// representation its benchmark callers rely on). Binding/wire consumers that
294    /// render per-sheet panels want sheet-local coordinates instead; this is the
295    /// single shared transform they apply at the response boundary.
296    ///
297    /// `extent` is the boundary's x-axis extent (`b_max[0] - b_min[0]`), identical
298    /// to the `strip_width` used during solving. After this call every placement's
299    /// x lies in `[0, extent)` (modulo spacing/margin) within its sheet.
300    pub fn to_boundary_local(&mut self, extent: f64) {
301        for placement in &mut self.placements {
302            if let Some(x) = placement.position.first_mut() {
303                *x -= placement.boundary_index as f64 * extent;
304            }
305        }
306    }
307}
308
309/// Summary statistics for a solve result.
310#[derive(Debug, Clone)]
311#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
312pub struct SolveSummary {
313    /// Total geometries requested.
314    pub total_requested: usize,
315    /// Total geometries placed.
316    pub total_placed: usize,
317    /// Utilization percentage.
318    pub utilization_percent: f64,
319    /// Number of bins/boundaries used.
320    pub bins_used: usize,
321    /// Computation time in milliseconds.
322    pub time_ms: u64,
323    /// Strategy used.
324    pub strategy: String,
325}
326
327impl<S> From<&SolveResult<S>> for SolveSummary {
328    fn from(result: &SolveResult<S>) -> Self {
329        Self {
330            // `result.unplaced` is deduplicated to unique geometry IDs, so
331            // `placements.len() + unplaced.len()` undercounts whenever a
332            // multi-quantity geometry is partially/fully unplaced. Use the
333            // authoritative instance-level total recorded at solve time.
334            total_requested: result.total_requested,
335            total_placed: result.placements.len(),
336            utilization_percent: result.utilization * 100.0,
337            bins_used: result.boundaries_used,
338            time_ms: result.computation_time_ms,
339            strategy: result
340                .strategy
341                .clone()
342                .unwrap_or_else(|| "unknown".to_string()),
343        }
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn test_result_new() {
353        let result: SolveResult<f64> = SolveResult::new();
354        assert!(result.placements.is_empty());
355        assert_eq!(result.utilization, 0.0);
356        assert!(result.all_placed());
357    }
358
359    #[test]
360    fn test_result_with_placements() {
361        let mut result: SolveResult<f64> = SolveResult::new();
362        result
363            .placements
364            .push(Placement::new_2d("test".to_string(), 0, 0.0, 0.0, 0.0));
365        result.utilization = 0.85;
366
367        assert_eq!(result.placed_count(), 1);
368        assert!(result.is_successful());
369        assert_eq!(result.utilization_percent(), "85.0%");
370    }
371
372    #[test]
373    fn test_result_with_unplaced() {
374        let mut result: SolveResult<f64> = SolveResult::new();
375        result.unplaced.push("G1".to_string());
376        result.unplaced.push("G2".to_string());
377
378        assert!(!result.all_placed());
379        assert_eq!(result.unplaced_count(), 2);
380    }
381
382    #[test]
383    fn test_solve_summary() {
384        let mut result: SolveResult<f64> = SolveResult::new();
385        result
386            .placements
387            .push(Placement::new_2d("test".to_string(), 0, 0.0, 0.0, 0.0));
388        result.utilization = 0.75;
389        result.boundaries_used = 1;
390        result.computation_time_ms = 100;
391        result.strategy = Some("GA".to_string());
392
393        let summary = SolveSummary::from(&result);
394        assert_eq!(summary.total_placed, 1);
395        assert_eq!(summary.utilization_percent, 75.0);
396        assert_eq!(summary.strategy, "GA");
397    }
398
399    #[test]
400    fn test_solve_summary_total_requested_is_instance_level() {
401        // Reproduces the reported defect: one geometry with quantity 5, of which
402        // only 1 instance is placed. `unplaced` is deduplicated to the single
403        // failing geometry ID. The old summary formula
404        // (placements.len() + unplaced.len() = 1 + 1 = 2) undercounts the true
405        // request total. With the authoritative `total_requested`, it is 5.
406        let mut result: SolveResult<f64> = SolveResult::new();
407        result
408            .placements
409            .push(Placement::new_2d("A".to_string(), 0, 0.0, 0.0, 0.0));
410        result.unplaced.push("A".to_string()); // deduplicated single ID
411        result.total_requested = 5;
412
413        let summary = SolveSummary::from(&result);
414        assert_eq!(summary.total_requested, 5);
415        assert_eq!(summary.total_placed, 1);
416        // Instance-level unplaced count, recovered by consumers:
417        assert_eq!(summary.total_requested - summary.total_placed, 4);
418    }
419
420    #[test]
421    fn test_merge_sums_total_requested() {
422        let mut a: SolveResult<f64> = SolveResult::new();
423        a.total_requested = 3;
424        let mut b: SolveResult<f64> = SolveResult::new();
425        b.total_requested = 4;
426        a.merge(b, 0);
427        assert_eq!(a.total_requested, 7);
428    }
429
430    #[test]
431    fn test_deduplicate_unplaced() {
432        let mut result: SolveResult<f64> = SolveResult::new();
433        // Simulate multiple instances of same geometry failing to place
434        result.unplaced.push("G1".to_string());
435        result.unplaced.push("G1".to_string());
436        result.unplaced.push("G2".to_string());
437        result.unplaced.push("G1".to_string());
438        result.unplaced.push("G2".to_string());
439
440        assert_eq!(result.unplaced.len(), 5);
441
442        result.deduplicate_unplaced();
443
444        assert_eq!(result.unplaced.len(), 2);
445        assert!(result.unplaced.contains(&"G1".to_string()));
446        assert!(result.unplaced.contains(&"G2".to_string()));
447    }
448
449    #[test]
450    fn test_to_boundary_local_subtracts_per_sheet_offset() {
451        // Three placements at the same sheet-local x=10, on sheets 0/1/2, in a global
452        // strip frame (x = local + sheet * 100). Localizing must recover x=10 on each.
453        let extent = 100.0;
454        let mut result: SolveResult<f64> = SolveResult::new();
455        for sheet in 0..3 {
456            result.placements.push(
457                Placement::new_2d(
458                    "p".to_string(),
459                    sheet,
460                    10.0 + sheet as f64 * extent,
461                    5.0,
462                    0.0,
463                )
464                .with_boundary(sheet),
465            );
466        }
467
468        result.to_boundary_local(extent);
469
470        for p in &result.placements {
471            assert_eq!(p.position[0], 10.0, "x must be sheet-local after transform");
472            assert_eq!(p.position[1], 5.0, "y is untouched");
473        }
474    }
475
476    /// A baseline replaces the search's layout only when strictly better, and
477    /// the replacement keeps the search's provenance.
478    #[test]
479    fn a_search_is_floored_at_a_strictly_better_baseline() {
480        let layout = |placed: usize| {
481            let mut r: SolveResult<f64> = SolveResult::new();
482            for i in 0..placed {
483                r.placements
484                    .push(Placement::new_2d("p".to_string(), i, i as f64, 0.0, 0.0));
485            }
486            r
487        };
488        let more =
489            |a: &SolveResult<f64>, b: &SolveResult<f64>| a.placements.len() > b.placements.len();
490        let search = || {
491            layout(2)
492                .with_strategy("GA")
493                .with_generations(7)
494                .with_fitness_history(vec![1.0, 2.0])
495        };
496
497        let floored = search().floored_at([layout(1), layout(3), layout(2)], more);
498        assert_eq!(floored.placements.len(), 3);
499        assert_eq!(floored.strategy.as_deref(), Some("GA"));
500        assert_eq!(floored.generations, Some(7));
501        assert_eq!(floored.fitness_history, Some(vec![1.0, 2.0]));
502
503        // A tie keeps the search's own layout.
504        let mut tie = layout(2);
505        tie.placements[0].position[0] = 99.0;
506        let kept = search().floored_at([tie], more);
507        assert_eq!(kept.placements[0].position[0], 0.0);
508    }
509}