1use crate::geometry::GeometryId;
4use crate::placement::{Placement, PlacementStats};
5
6#[cfg(feature = "serde")]
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Default)]
11#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
12pub struct StripStats {
13 pub strip_index: usize,
15 pub used_length: f64,
17 pub piece_area: f64,
19 pub piece_count: usize,
21 pub strip_width: f64,
23 pub strip_height: f64,
25}
26
27#[derive(Debug, Clone)]
29#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
30pub struct SolveResult<S> {
31 pub placements: Vec<Placement<S>>,
33
34 pub boundaries_used: usize,
36
37 pub utilization: f64,
40
41 pub unplaced: Vec<GeometryId>,
43
44 pub computation_time_ms: u64,
46
47 pub generations: Option<u32>,
49
50 pub iterations: Option<u64>,
52
53 pub best_fitness: Option<f64>,
55
56 pub fitness_history: Option<Vec<f64>>,
58
59 pub strategy: Option<String>,
61
62 pub cancelled: bool,
64
65 pub target_reached: bool,
67
68 pub strip_stats: Vec<StripStats>,
71
72 pub total_piece_area: f64,
74
75 pub total_material_used: f64,
77
78 pub total_requested: usize,
84
85 pub used_bounding_box: [f64; 2],
92}
93
94impl<S> SolveResult<S> {
95 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 pub fn all_placed(&self) -> bool {
120 self.unplaced.is_empty()
121 }
122
123 pub fn placed_count(&self) -> usize {
125 self.placements.len()
126 }
127
128 pub fn unplaced_count(&self) -> usize {
130 self.unplaced.len()
131 }
132
133 pub fn is_successful(&self) -> bool {
135 !self.placements.is_empty()
136 }
137
138 pub fn completed_normally(&self) -> bool {
140 !self.cancelled
141 }
142
143 pub fn with_strategy(mut self, strategy: impl Into<String>) -> Self {
145 self.strategy = Some(strategy.into());
146 self
147 }
148
149 pub fn with_generations(mut self, generations: u32) -> Self {
151 self.generations = Some(generations);
152 self
153 }
154
155 pub fn with_best_fitness(mut self, fitness: f64) -> Self {
157 self.best_fitness = Some(fitness);
158 self
159 }
160
161 pub fn with_fitness_history(mut self, history: Vec<f64>) -> Self {
163 self.fitness_history = Some(history);
164 self
165 }
166
167 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 pub fn placement_stats(&self) -> PlacementStats {
176 PlacementStats::from_placements(&self.placements)
177 }
178
179 pub fn utilization_percent(&self) -> String {
181 format!("{:.1}%", self.utilization * 100.0)
182 }
183
184 pub fn merge(&mut self, other: SolveResult<S>, boundary_offset: usize) {
186 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 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 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 if self.total_material_used > 0.0 {
215 self.utilization = self.total_piece_area / self.total_material_used;
216 }
217 }
218
219 pub fn with_strip_stats(mut self, stats: Vec<StripStats>) -> Self {
221 self.strip_stats = stats;
222 self
223 }
224
225 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 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#[derive(Debug, Clone)]
276#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
277pub struct SolveSummary {
278 pub total_requested: usize,
280 pub total_placed: usize,
282 pub utilization_percent: f64,
284 pub bins_used: usize,
286 pub time_ms: u64,
288 pub strategy: String,
290}
291
292impl<S> From<&SolveResult<S>> for SolveSummary {
293 fn from(result: &SolveResult<S>) -> Self {
294 Self {
295 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 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()); 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 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 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 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}