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
27pub use u_metaheur::StopReason;
32
33#[derive(Debug, Clone)]
35#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
36pub struct SolveResult<S> {
37 pub placements: Vec<Placement<S>>,
39
40 pub boundaries_used: usize,
42
43 pub utilization: f64,
46
47 pub unplaced: Vec<GeometryId>,
49
50 pub computation_time_ms: u64,
52
53 pub generations: Option<u32>,
55
56 pub iterations: Option<u64>,
58
59 pub best_fitness: Option<f64>,
61
62 pub fitness_history: Option<Vec<f64>>,
64
65 pub strategy: Option<String>,
67
68 pub stop_reason: StopReason,
71
72 pub strip_stats: Vec<StripStats>,
75
76 pub total_piece_area: f64,
78
79 pub total_material_used: f64,
81
82 pub total_requested: usize,
88
89 pub used_bounding_box: [f64; 2],
96}
97
98impl<S> SolveResult<S> {
99 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 pub fn all_placed(&self) -> bool {
123 self.unplaced.is_empty()
124 }
125
126 pub fn placed_count(&self) -> usize {
128 self.placements.len()
129 }
130
131 pub fn unplaced_count(&self) -> usize {
133 self.unplaced.len()
134 }
135
136 pub fn is_successful(&self) -> bool {
138 !self.placements.is_empty()
139 }
140
141 pub fn with_strategy(mut self, strategy: impl Into<String>) -> Self {
143 self.strategy = Some(strategy.into());
144 self
145 }
146
147 pub fn with_generations(mut self, generations: u32) -> Self {
149 self.generations = Some(generations);
150 self
151 }
152
153 pub fn with_best_fitness(mut self, fitness: f64) -> Self {
155 self.best_fitness = Some(fitness);
156 self
157 }
158
159 pub fn with_fitness_history(mut self, history: Vec<f64>) -> Self {
161 self.fitness_history = Some(history);
162 self
163 }
164
165 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 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 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 pub fn placement_stats(&self) -> PlacementStats {
219 PlacementStats::from_placements(&self.placements)
220 }
221
222 pub fn utilization_percent(&self) -> String {
224 format!("{:.1}%", self.utilization * 100.0)
225 }
226
227 pub fn merge(&mut self, other: SolveResult<S>, boundary_offset: usize) {
229 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 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 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 if self.total_material_used > 0.0 {
258 self.utilization = self.total_piece_area / self.total_material_used;
259 }
260 }
261
262 pub fn with_strip_stats(mut self, stats: Vec<StripStats>) -> Self {
264 self.strip_stats = stats;
265 self
266 }
267
268 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 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
317pub 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#[derive(Debug, Clone)]
343#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
344pub struct SolveSummary {
345 pub total_requested: usize,
347 pub total_placed: usize,
349 pub utilization_percent: f64,
351 pub bins_used: usize,
353 pub time_ms: u64,
355 pub strategy: String,
357}
358
359impl<S> From<&SolveResult<S>> for SolveSummary {
360 fn from(result: &SolveResult<S>) -> Self {
361 Self {
362 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 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()); 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 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 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 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 #[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 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}