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/// Why a solve stopped -- the same vocabulary every runner in `u-metaheur`
28/// reports, so a nesting run and any other search say it in one set of words.
29/// Each strategy here sets it where it stops; `Completed` is a fill that tried
30/// every piece, or a search that spent the budget it is given internally.
31pub use u_metaheur::StopReason;
32
33/// Result of a nesting or packing solve operation.
34#[derive(Debug, Clone)]
35#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
36pub struct SolveResult<S> {
37    /// List of placements for all successfully placed geometry instances.
38    pub placements: Vec<Placement<S>>,
39
40    /// Number of boundaries (bins) used.
41    pub boundaries_used: usize,
42
43    /// Utilization ratio (0.0 - 1.0).
44    /// Calculated as: total_geometry_measure / total_boundary_measure
45    pub utilization: f64,
46
47    /// IDs of geometries that could not be placed.
48    pub unplaced: Vec<GeometryId>,
49
50    /// Computation time in milliseconds.
51    pub computation_time_ms: u64,
52
53    /// Number of generations (for GA-based solvers).
54    pub generations: Option<u32>,
55
56    /// Number of iterations (for SA-based solvers).
57    pub iterations: Option<u64>,
58
59    /// Best fitness value achieved (for GA/SA-based solvers).
60    pub best_fitness: Option<f64>,
61
62    /// Fitness history over generations (for analysis).
63    pub fitness_history: Option<Vec<f64>>,
64
65    /// Strategy used for solving.
66    pub strategy: Option<String>,
67
68    /// Why the run stopped -- at the time limit, at the target, on
69    /// cancellation, or at its own end.
70    pub stop_reason: StopReason,
71
72    /// Per-strip statistics for multi-strip packing.
73    /// Contains used_length, piece_area, piece_count for each strip.
74    pub strip_stats: Vec<StripStats>,
75
76    /// Total area of all placed pieces.
77    pub total_piece_area: f64,
78
79    /// Total material area consumed (sum of strip_width × used_length for each strip).
80    pub total_material_used: f64,
81
82    /// Total number of geometry **instances** requested (Σ of every geometry's
83    /// quantity). This is the instance-level denominator: the count of unplaced
84    /// instances is `total_requested - placements.len()`, since `placements` is
85    /// instance-level while `unplaced` is deduplicated to unique geometry IDs.
86    /// Set once at the top-level `solve`/`solve_with_progress` entry point.
87    pub total_requested: usize,
88
89    /// Axis-aligned bounding box `[width, height]` of the placed pieces' actual
90    /// footprint (2D). Unlike `utilization` (piece area over the *full* boundary,
91    /// which shrinks arbitrarily as boundary height grows), the used bounding box
92    /// is boundary-padding independent — for an open-ended roll the larger axis is
93    /// the material length actually consumed. `[0.0, 0.0]` when nothing is placed.
94    /// Populated at the 2D validation/filter step; unused by 3D packing.
95    pub used_bounding_box: [f64; 2],
96}
97
98impl<S> SolveResult<S> {
99    /// Creates a new empty result.
100    pub fn new() -> Self {
101        Self {
102            placements: Vec::new(),
103            boundaries_used: 0,
104            utilization: 0.0,
105            unplaced: Vec::new(),
106            computation_time_ms: 0,
107            generations: None,
108            iterations: None,
109            best_fitness: None,
110            fitness_history: None,
111            strategy: None,
112            stop_reason: StopReason::Completed,
113            strip_stats: Vec::new(),
114            total_piece_area: 0.0,
115            total_material_used: 0.0,
116            total_requested: 0,
117            used_bounding_box: [0.0, 0.0],
118        }
119    }
120
121    /// Returns true if all geometries were placed.
122    pub fn all_placed(&self) -> bool {
123        self.unplaced.is_empty()
124    }
125
126    /// Returns the number of placed geometry instances.
127    pub fn placed_count(&self) -> usize {
128        self.placements.len()
129    }
130
131    /// Returns the number of unplaced geometry types.
132    pub fn unplaced_count(&self) -> usize {
133        self.unplaced.len()
134    }
135
136    /// Returns true if the solve was successful (at least one placement).
137    pub fn is_successful(&self) -> bool {
138        !self.placements.is_empty()
139    }
140
141    /// Sets the strategy name.
142    pub fn with_strategy(mut self, strategy: impl Into<String>) -> Self {
143        self.strategy = Some(strategy.into());
144        self
145    }
146
147    /// Sets the generations count.
148    pub fn with_generations(mut self, generations: u32) -> Self {
149        self.generations = Some(generations);
150        self
151    }
152
153    /// Sets the best fitness.
154    pub fn with_best_fitness(mut self, fitness: f64) -> Self {
155        self.best_fitness = Some(fitness);
156        self
157    }
158
159    /// Sets the fitness history.
160    pub fn with_fitness_history(mut self, history: Vec<f64>) -> Self {
161        self.fitness_history = Some(history);
162        self
163    }
164
165    /// The best of a search's result (`self`) and `baselines` -- layouts a
166    /// simple deterministic fill produced for the same request -- where `beats`
167    /// says whether its first argument is strictly better than its second.
168    ///
169    /// A search can stop, on a short budget, at a layout the simple fill would
170    /// have beaten; flooring it here means a search never returns less. A tie
171    /// keeps the search's layout. When a baseline wins, its placements are
172    /// returned with the search's provenance -- strategy, generations,
173    /// iterations, fitness history, stop reason -- because the search did run.
174    pub fn floored_at(
175        self,
176        baselines: impl IntoIterator<Item = Self>,
177        beats: impl Fn(&Self, &Self) -> bool,
178    ) -> Self {
179        let mut best: Option<Self> = None;
180        for baseline in baselines {
181            if beats(&baseline, best.as_ref().unwrap_or(&self)) {
182                best = Some(baseline);
183            }
184        }
185        match best {
186            Some(mut floored) => {
187                floored.strategy = self.strategy;
188                floored.generations = self.generations;
189                floored.iterations = self.iterations;
190                floored.best_fitness = self.best_fitness;
191                floored.fitness_history = self.fitness_history;
192                floored.stop_reason = self.stop_reason;
193                floored
194            }
195            None => self,
196        }
197    }
198
199    /// Removes duplicate entries from the unplaced list.
200    /// This is useful when multiple instances of the same geometry failed to place.
201    pub fn deduplicate_unplaced(&mut self) {
202        let mut seen = std::collections::HashSet::new();
203        self.unplaced.retain(|id| seen.insert(id.clone()));
204    }
205
206    /// Sets `unplaced` from the placements: each geometry with a copy the
207    /// placements do not contain, once, in request order.
208    ///
209    /// The layout is the one fact; what it leaves short follows from it, so
210    /// a strategy that stops early (time limit, cancellation) cannot report a
211    /// list that disagrees with what it placed.
212    pub fn record_unplaced<G: crate::geometry::Geometry>(&mut self, geometries: &[G]) {
213        self.unplaced = unplaced_ids(&self.placements, geometries);
214        self.deduplicate_unplaced();
215    }
216
217    /// Computes placement statistics.
218    pub fn placement_stats(&self) -> PlacementStats {
219        PlacementStats::from_placements(&self.placements)
220    }
221
222    /// Returns utilization as a percentage string.
223    pub fn utilization_percent(&self) -> String {
224        format!("{:.1}%", self.utilization * 100.0)
225    }
226
227    /// Merges placements from another result (for multi-bin scenarios).
228    pub fn merge(&mut self, other: SolveResult<S>, boundary_offset: usize) {
229        // Offset boundary indices
230        for mut placement in other.placements {
231            placement.boundary_index += boundary_offset;
232            self.placements.push(placement);
233        }
234
235        self.boundaries_used = self
236            .boundaries_used
237            .max(other.boundaries_used + boundary_offset);
238        self.unplaced.extend(other.unplaced);
239        self.computation_time_ms += other.computation_time_ms;
240
241        // Merge strip stats with offset
242        for mut strip_stat in other.strip_stats {
243            strip_stat.strip_index += boundary_offset;
244            self.strip_stats.push(strip_stat);
245        }
246        self.total_piece_area += other.total_piece_area;
247        self.total_material_used += other.total_material_used;
248        self.total_requested += other.total_requested;
249        // Used footprints live in per-sheet local frames, so a union box is
250        // ill-defined across sheets; report the element-wise max extent seen.
251        self.used_bounding_box = [
252            self.used_bounding_box[0].max(other.used_bounding_box[0]),
253            self.used_bounding_box[1].max(other.used_bounding_box[1]),
254        ];
255
256        // Recalculate utilization
257        if self.total_material_used > 0.0 {
258            self.utilization = self.total_piece_area / self.total_material_used;
259        }
260    }
261
262    /// Sets strip statistics.
263    pub fn with_strip_stats(mut self, stats: Vec<StripStats>) -> Self {
264        self.strip_stats = stats;
265        self
266    }
267
268    /// Calculates and sets utilization from strip stats.
269    /// This is the accurate utilization based on actual material consumed.
270    pub fn calculate_utilization(&mut self) {
271        if self.strip_stats.is_empty() {
272            return;
273        }
274
275        self.total_piece_area = self.strip_stats.iter().map(|s| s.piece_area).sum();
276        self.total_material_used = self
277            .strip_stats
278            .iter()
279            .map(|s| s.strip_width * s.used_length)
280            .sum();
281
282        if self.total_material_used > 0.0 {
283            self.utilization = self.total_piece_area / self.total_material_used;
284        }
285    }
286}
287
288impl<S> Default for SolveResult<S> {
289    fn default() -> Self {
290        Self::new()
291    }
292}
293
294impl SolveResult<f64> {
295    /// Rewrites placement x-coordinates from the global multi-strip frame
296    /// (sheets laid side-by-side: `x ≈ boundary_index * extent`) into the
297    /// per-boundary **local** frame, so each placement's x is relative to the
298    /// origin of its own sheet (`boundary_index` selects the sheet).
299    ///
300    /// `solve_multi_strip` emits global strip coordinates (the strip-packing
301    /// representation its benchmark callers rely on). Binding/wire consumers that
302    /// render per-sheet panels want sheet-local coordinates instead; this is the
303    /// single shared transform they apply at the response boundary.
304    ///
305    /// `extent` is the boundary's x-axis extent (`b_max[0] - b_min[0]`), identical
306    /// to the `strip_width` used during solving. After this call every placement's
307    /// x lies in `[0, extent)` (modulo spacing/margin) within its sheet.
308    pub fn to_boundary_local(&mut self, extent: f64) {
309        for placement in &mut self.placements {
310            if let Some(x) = placement.position.first_mut() {
311                *x -= placement.boundary_index as f64 * extent;
312            }
313        }
314    }
315}
316
317/// The geometry ids with a copy `placements` does not contain -- one entry
318/// per missing copy, in request order.
319///
320/// A geometry with a quantity counts per copy: two of three copies placed
321/// leaves its id unplaced.
322pub fn unplaced_ids<S, G: crate::geometry::Geometry>(
323    placements: &[Placement<S>],
324    geometries: &[G],
325) -> Vec<GeometryId> {
326    let placed: std::collections::HashSet<(&str, usize)> = placements
327        .iter()
328        .map(|p| (p.geometry_id.as_str(), p.instance))
329        .collect();
330    let mut unplaced = Vec::new();
331    for geom in geometries {
332        for instance in 0..geom.quantity() {
333            if !placed.contains(&(geom.id().as_str(), instance)) {
334                unplaced.push(geom.id().clone());
335            }
336        }
337    }
338    unplaced
339}
340
341/// Summary statistics for a solve result.
342#[derive(Debug, Clone)]
343#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
344pub struct SolveSummary {
345    /// Total geometries requested.
346    pub total_requested: usize,
347    /// Total geometries placed.
348    pub total_placed: usize,
349    /// Utilization percentage.
350    pub utilization_percent: f64,
351    /// Number of bins/boundaries used.
352    pub bins_used: usize,
353    /// Computation time in milliseconds.
354    pub time_ms: u64,
355    /// Strategy used.
356    pub strategy: String,
357}
358
359impl<S> From<&SolveResult<S>> for SolveSummary {
360    fn from(result: &SolveResult<S>) -> Self {
361        Self {
362            // `result.unplaced` is deduplicated to unique geometry IDs, so
363            // `placements.len() + unplaced.len()` undercounts whenever a
364            // multi-quantity geometry is partially/fully unplaced. Use the
365            // authoritative instance-level total recorded at solve time.
366            total_requested: result.total_requested,
367            total_placed: result.placements.len(),
368            utilization_percent: result.utilization * 100.0,
369            bins_used: result.boundaries_used,
370            time_ms: result.computation_time_ms,
371            strategy: result
372                .strategy
373                .clone()
374                .unwrap_or_else(|| "unknown".to_string()),
375        }
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn test_result_new() {
385        let result: SolveResult<f64> = SolveResult::new();
386        assert!(result.placements.is_empty());
387        assert_eq!(result.utilization, 0.0);
388        assert!(result.all_placed());
389    }
390
391    #[test]
392    fn test_result_with_placements() {
393        let mut result: SolveResult<f64> = SolveResult::new();
394        result
395            .placements
396            .push(Placement::new_2d("test".to_string(), 0, 0.0, 0.0, 0.0));
397        result.utilization = 0.85;
398
399        assert_eq!(result.placed_count(), 1);
400        assert!(result.is_successful());
401        assert_eq!(result.utilization_percent(), "85.0%");
402    }
403
404    #[test]
405    fn test_result_with_unplaced() {
406        let mut result: SolveResult<f64> = SolveResult::new();
407        result.unplaced.push("G1".to_string());
408        result.unplaced.push("G2".to_string());
409
410        assert!(!result.all_placed());
411        assert_eq!(result.unplaced_count(), 2);
412    }
413
414    #[test]
415    fn test_solve_summary() {
416        let mut result: SolveResult<f64> = SolveResult::new();
417        result
418            .placements
419            .push(Placement::new_2d("test".to_string(), 0, 0.0, 0.0, 0.0));
420        result.utilization = 0.75;
421        result.boundaries_used = 1;
422        result.computation_time_ms = 100;
423        result.strategy = Some("GA".to_string());
424
425        let summary = SolveSummary::from(&result);
426        assert_eq!(summary.total_placed, 1);
427        assert_eq!(summary.utilization_percent, 75.0);
428        assert_eq!(summary.strategy, "GA");
429    }
430
431    #[test]
432    fn test_solve_summary_total_requested_is_instance_level() {
433        // Reproduces the reported defect: one geometry with quantity 5, of which
434        // only 1 instance is placed. `unplaced` is deduplicated to the single
435        // failing geometry ID. The old summary formula
436        // (placements.len() + unplaced.len() = 1 + 1 = 2) undercounts the true
437        // request total. With the authoritative `total_requested`, it is 5.
438        let mut result: SolveResult<f64> = SolveResult::new();
439        result
440            .placements
441            .push(Placement::new_2d("A".to_string(), 0, 0.0, 0.0, 0.0));
442        result.unplaced.push("A".to_string()); // deduplicated single ID
443        result.total_requested = 5;
444
445        let summary = SolveSummary::from(&result);
446        assert_eq!(summary.total_requested, 5);
447        assert_eq!(summary.total_placed, 1);
448        // Instance-level unplaced count, recovered by consumers:
449        assert_eq!(summary.total_requested - summary.total_placed, 4);
450    }
451
452    #[test]
453    fn test_merge_sums_total_requested() {
454        let mut a: SolveResult<f64> = SolveResult::new();
455        a.total_requested = 3;
456        let mut b: SolveResult<f64> = SolveResult::new();
457        b.total_requested = 4;
458        a.merge(b, 0);
459        assert_eq!(a.total_requested, 7);
460    }
461
462    #[test]
463    fn test_deduplicate_unplaced() {
464        let mut result: SolveResult<f64> = SolveResult::new();
465        // Simulate multiple instances of same geometry failing to place
466        result.unplaced.push("G1".to_string());
467        result.unplaced.push("G1".to_string());
468        result.unplaced.push("G2".to_string());
469        result.unplaced.push("G1".to_string());
470        result.unplaced.push("G2".to_string());
471
472        assert_eq!(result.unplaced.len(), 5);
473
474        result.deduplicate_unplaced();
475
476        assert_eq!(result.unplaced.len(), 2);
477        assert!(result.unplaced.contains(&"G1".to_string()));
478        assert!(result.unplaced.contains(&"G2".to_string()));
479    }
480
481    #[test]
482    fn test_to_boundary_local_subtracts_per_sheet_offset() {
483        // Three placements at the same sheet-local x=10, on sheets 0/1/2, in a global
484        // strip frame (x = local + sheet * 100). Localizing must recover x=10 on each.
485        let extent = 100.0;
486        let mut result: SolveResult<f64> = SolveResult::new();
487        for sheet in 0..3 {
488            result.placements.push(
489                Placement::new_2d(
490                    "p".to_string(),
491                    sheet,
492                    10.0 + sheet as f64 * extent,
493                    5.0,
494                    0.0,
495                )
496                .with_boundary(sheet),
497            );
498        }
499
500        result.to_boundary_local(extent);
501
502        for p in &result.placements {
503            assert_eq!(p.position[0], 10.0, "x must be sheet-local after transform");
504            assert_eq!(p.position[1], 5.0, "y is untouched");
505        }
506    }
507
508    /// A baseline replaces the search's layout only when strictly better, and
509    /// the replacement keeps the search's provenance.
510    #[test]
511    fn a_search_is_floored_at_a_strictly_better_baseline() {
512        let layout = |placed: usize| {
513            let mut r: SolveResult<f64> = SolveResult::new();
514            for i in 0..placed {
515                r.placements
516                    .push(Placement::new_2d("p".to_string(), i, i as f64, 0.0, 0.0));
517            }
518            r
519        };
520        let more =
521            |a: &SolveResult<f64>, b: &SolveResult<f64>| a.placements.len() > b.placements.len();
522        let search = || {
523            layout(2)
524                .with_strategy("GA")
525                .with_generations(7)
526                .with_fitness_history(vec![1.0, 2.0])
527        };
528
529        let floored = search().floored_at([layout(1), layout(3), layout(2)], more);
530        assert_eq!(floored.placements.len(), 3);
531        assert_eq!(floored.strategy.as_deref(), Some("GA"));
532        assert_eq!(floored.generations, Some(7));
533        assert_eq!(floored.fitness_history, Some(vec![1.0, 2.0]));
534
535        // A tie keeps the search's own layout.
536        let mut tie = layout(2);
537        tie.placements[0].position[0] = 99.0;
538        let kept = search().floored_at([tie], more);
539        assert_eq!(kept.placements[0].position[0], 0.0);
540    }
541}