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 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 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 pub fn placement_stats(&self) -> PlacementStats {
211 PlacementStats::from_placements(&self.placements)
212 }
213
214 pub fn utilization_percent(&self) -> String {
216 format!("{:.1}%", self.utilization * 100.0)
217 }
218
219 pub fn merge(&mut self, other: SolveResult<S>, boundary_offset: usize) {
221 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 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 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 if self.total_material_used > 0.0 {
250 self.utilization = self.total_piece_area / self.total_material_used;
251 }
252 }
253
254 pub fn with_strip_stats(mut self, stats: Vec<StripStats>) -> Self {
256 self.strip_stats = stats;
257 self
258 }
259
260 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 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#[derive(Debug, Clone)]
311#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
312pub struct SolveSummary {
313 pub total_requested: usize,
315 pub total_placed: usize,
317 pub utilization_percent: f64,
319 pub bins_used: usize,
321 pub time_ms: u64,
323 pub strategy: String,
325}
326
327impl<S> From<&SolveResult<S>> for SolveSummary {
328 fn from(result: &SolveResult<S>) -> Self {
329 Self {
330 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 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()); 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 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 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 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 #[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 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}