Skip to main content

u_nesting_d3/
packer.rs

1//! 3D bin packing solver.
2
3use crate::boundary::Boundary3D;
4use crate::brkga_packing::run_brkga_packing;
5use crate::extreme_point::run_ep_packing;
6use crate::ga_packing::run_ga_packing;
7use crate::geometry::Geometry3D;
8use crate::physics::{PhysicsConfig, PhysicsSimulator};
9use crate::sa_packing::run_sa_packing;
10use crate::stability::{PlacedBox, StabilityAnalyzer, StabilityConstraint, StabilityReport};
11use u_nesting_core::brkga::BrkgaConfig;
12use u_nesting_core::ga::GaConfig;
13use u_nesting_core::geom::nalgebra_types::{NaPoint3 as Point3, NaVector3 as Vector3};
14use u_nesting_core::geometry::{Boundary, Geometry};
15use u_nesting_core::sa::SaConfig;
16use u_nesting_core::solver::{Config, ProgressCallback, ProgressInfo, Solver, Strategy};
17use u_nesting_core::{Error, Placement, Result, SolveResult};
18
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::Arc;
21use u_nesting_core::timing::Timer;
22
23/// Best fit candidate for 3D placement.
24/// (orientation_idx, width, depth, height, place_x, place_y, place_z, new_row_depth, new_layer_height)
25type BestFit3D = Option<(usize, f64, f64, f64, f64, f64, f64, f64, f64)>;
26
27/// 3D bin packing solver.
28pub struct Packer3D {
29    config: Config,
30    cancelled: Arc<AtomicBool>,
31}
32
33impl Packer3D {
34    /// Runs the configured strategy. A strategy 3D packing does not provide is
35    /// refused rather than replaced by layer packing.
36    fn run_strategy(
37        &self,
38        geometries: &[Geometry3D],
39        boundary: &Boundary3D,
40    ) -> Result<SolveResult<f64>> {
41        match self.config.strategy {
42            Strategy::BottomLeftFill => self.layer_packing(geometries, boundary),
43            Strategy::ExtremePoint => self.extreme_point(geometries, boundary),
44            Strategy::GeneticAlgorithm => self.genetic_algorithm(geometries, boundary),
45            Strategy::Brkga => self.brkga(geometries, boundary),
46            Strategy::SimulatedAnnealing => self.simulated_annealing(geometries, boundary),
47            other => Err(Error::ConfigError(format!(
48                "strategy {other:?} is not available for 3D packing; it offers \
49                 BottomLeftFill, ExtremePoint, GeneticAlgorithm, Brkga and SimulatedAnnealing"
50            ))),
51        }
52    }
53
54    /// Creates a new packer with the given configuration.
55    pub fn new(config: Config) -> Self {
56        Self {
57            config,
58            cancelled: Arc::new(AtomicBool::new(false)),
59        }
60    }
61
62    /// Creates a packer with default configuration.
63    pub fn default_config() -> Self {
64        Self::new(Config::default())
65    }
66
67    /// Validates the stability of a packing result.
68    ///
69    /// Analyzes each placement to ensure boxes are properly supported.
70    pub fn validate_stability(
71        &self,
72        result: &SolveResult<f64>,
73        geometries: &[Geometry3D],
74        _boundary: &Boundary3D,
75        constraint: StabilityConstraint,
76    ) -> StabilityReport {
77        // Convert placements to PlacedBox format
78        let placed_boxes = self.placements_to_boxes(result, geometries);
79        let analyzer = StabilityAnalyzer::new(constraint);
80        analyzer.analyze(&placed_boxes, 0.0)
81    }
82
83    /// Validates stability using physics simulation.
84    ///
85    /// Runs a physics simulation to detect boxes that would fall or tip.
86    pub fn validate_stability_physics(
87        &self,
88        result: &SolveResult<f64>,
89        geometries: &[Geometry3D],
90        boundary: &Boundary3D,
91    ) -> StabilityReport {
92        let placed_boxes = self.placements_to_boxes(result, geometries);
93        let container = Vector3::new(boundary.width(), boundary.depth(), boundary.height());
94
95        let config = PhysicsConfig::default().with_max_time(2.0);
96        let simulator = PhysicsSimulator::new(config);
97        simulator.validate_stability(&placed_boxes, container, 0.0)
98    }
99
100    /// Enforces the boundary's gravity/stability constraints on a finished packing.
101    ///
102    /// Strategies optimise for volume, not support, so a result may contain boxes that
103    /// float or rest on too little base. When the boundary requests gravity and/or
104    /// stability, this removes the offending boxes (moving them to `unplaced`) so the
105    /// returned packing actually satisfies the constraint the caller asked for — rather
106    /// than the flags being silently ignored.
107    ///
108    /// Removal iterates: dropping a box can leave the boxes it supported unsupported, so
109    /// the result is re-validated until every remaining box is stable.
110    fn enforce_support(
111        &self,
112        result: &mut SolveResult<f64>,
113        geometries: &[Geometry3D],
114        boundary: &Boundary3D,
115    ) {
116        // Stability is the stronger requirement (a real base-support ratio); plain
117        // gravity only forbids floating, i.e. demands any positive contact below.
118        let constraint = if boundary.has_stability() {
119            StabilityConstraint::partial_base(0.7)
120        } else {
121            StabilityConstraint::partial_base(1e-6)
122        };
123
124        // The pack rests boxes on z = margin (the inset floor), so the support analysis
125        // must use that as the floor — checking against z = 0 would read every bottom
126        // box as floating and drop the whole pack when margin > 0.
127        let floor_z = self.config.margin;
128        let analyzer = StabilityAnalyzer::new(constraint);
129        while !result.placements.is_empty() {
130            let placed_boxes = self.placements_to_boxes(result, geometries);
131            let report = analyzer.analyze(&placed_boxes, floor_z);
132            if report.is_all_stable() {
133                break;
134            }
135            let drop: std::collections::HashSet<(String, usize)> = report
136                .unstable_boxes()
137                .iter()
138                .map(|r| (r.id.clone(), r.instance))
139                .collect();
140            result
141                .placements
142                .retain(|p| !drop.contains(&(p.geometry_id.clone(), p.instance)));
143            for (id, _) in drop {
144                result.unplaced.push(id);
145            }
146        }
147
148        result.deduplicate_unplaced();
149        // Placed volume shrank, so the reported utilization must follow.
150        let boxes = self.placements_to_boxes(result, geometries);
151        let placed_volume: f64 = boxes
152            .iter()
153            .map(|b| b.dimensions.x * b.dimensions.y * b.dimensions.z)
154            .sum();
155        let container_volume = boundary.measure();
156        result.utilization = if container_volume > 0.0 {
157            placed_volume / container_volume
158        } else {
159            0.0
160        };
161    }
162
163    /// Converts placements to PlacedBox format for stability analysis.
164    fn placements_to_boxes(
165        &self,
166        result: &SolveResult<f64>,
167        geometries: &[Geometry3D],
168    ) -> Vec<PlacedBox> {
169        let geom_map: std::collections::HashMap<&str, &Geometry3D> =
170            geometries.iter().map(|g| (g.id().as_str(), g)).collect();
171
172        result
173            .placements
174            .iter()
175            .filter_map(|p| {
176                let geom = geom_map.get(p.geometry_id.as_str())?;
177                let ori_idx = p.rotation_index.unwrap_or(0);
178                let dims = geom.dimensions_for_orientation(ori_idx);
179
180                let mut placed = PlacedBox::new(
181                    p.geometry_id.clone(),
182                    p.instance,
183                    Point3::new(p.position[0], p.position[1], p.position[2]),
184                    dims,
185                );
186
187                if let Some(mass) = geom.mass() {
188                    placed = placed.with_mass(mass);
189                }
190
191                Some(placed)
192            })
193            .collect()
194    }
195
196    /// Simple layer-based packing algorithm.
197    fn layer_packing(
198        &self,
199        geometries: &[Geometry3D],
200        boundary: &Boundary3D,
201    ) -> Result<SolveResult<f64>> {
202        let start = Timer::now();
203        let mut result = SolveResult::new();
204        let mut placements = Vec::new();
205
206        let margin = self.config.margin;
207        let spacing = self.config.spacing;
208
209        let bound_max_x = boundary.width() - margin;
210        let bound_max_y = boundary.depth() - margin;
211        let bound_max_z = boundary.height() - margin;
212
213        // Simple layer-based placement
214        let mut current_x = margin;
215        let mut current_y = margin;
216        let mut current_z = margin;
217        let mut row_depth = 0.0_f64;
218        let mut layer_height = 0.0_f64;
219
220        let mut total_placed_volume = 0.0;
221        let mut total_placed_mass = 0.0;
222
223        for geom in geometries {
224            geom.validate()?;
225
226            for instance in 0..geom.quantity() {
227                if self.cancelled.load(Ordering::Relaxed) {
228                    result.computation_time_ms = start.elapsed_ms();
229                    return Ok(result);
230                }
231
232                // Check time limit (0 = unlimited)
233                if self.config.time_limit_ms > 0 && start.elapsed_ms() >= self.config.time_limit_ms
234                {
235                    result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
236                    result.utilization = total_placed_volume / boundary.measure();
237                    result.computation_time_ms = start.elapsed_ms();
238                    result.placements = placements;
239                    return Ok(result);
240                }
241
242                // Check mass constraint
243                if let (Some(max_mass), Some(item_mass)) = (boundary.max_mass(), geom.mass()) {
244                    if total_placed_mass + item_mass > max_mass {
245                        result.unplaced.push(geom.id().clone());
246                        continue;
247                    }
248                }
249
250                // Try all allowed orientations to find the best fit
251                let orientations = geom.allowed_orientations();
252                let mut best_fit: BestFit3D = None;
253                // (orientation_idx, width, depth, height, place_x, place_y, place_z, new_row_depth, new_layer_height)
254
255                for (ori_idx, _) in orientations.iter().enumerate() {
256                    let dims = geom.dimensions_for_orientation(ori_idx);
257                    let g_width = dims.x;
258                    let g_depth = dims.y;
259                    let g_height = dims.z;
260
261                    // Try current position first
262                    let mut try_x = current_x;
263                    let mut try_y = current_y;
264                    let mut try_z = current_z;
265                    let mut try_row_depth = row_depth;
266                    let mut try_layer_height = layer_height;
267
268                    // Check if fits in current row
269                    if try_x + g_width > bound_max_x {
270                        try_x = margin;
271                        try_y += row_depth + spacing;
272                        try_row_depth = 0.0;
273                    }
274
275                    // Check if fits in current layer
276                    if try_y + g_depth > bound_max_y {
277                        try_x = margin;
278                        try_y = margin;
279                        try_z += layer_height + spacing;
280                        try_row_depth = 0.0;
281                        try_layer_height = 0.0;
282                    }
283
284                    // Check if fits in container. A fresh row or layer starts at
285                    // the margin, so an orientation wider or deeper than the
286                    // container never fits; without the x/y checks it was placed
287                    // hanging out of the container.
288                    if try_x + g_width > bound_max_x
289                        || try_y + g_depth > bound_max_y
290                        || try_z + g_height > bound_max_z
291                    {
292                        continue; // This orientation doesn't fit
293                    }
294
295                    // Score: prefer placements that use less vertical space (height)
296                    // and stay in current row (lower y advancement)
297                    let score = try_z * 1000000.0 + try_y * 1000.0 + try_x + g_height * 0.1;
298
299                    let is_better = match &best_fit {
300                        None => true,
301                        Some((_, _, _, bg_height, bx, by, bz, _, _)) => {
302                            let best_score = bz * 1000000.0 + by * 1000.0 + bx + bg_height * 0.1;
303                            score < best_score
304                        }
305                    };
306
307                    if is_better {
308                        best_fit = Some((
309                            ori_idx,
310                            g_width,
311                            g_depth,
312                            g_height,
313                            try_x,
314                            try_y,
315                            try_z,
316                            try_row_depth,
317                            try_layer_height,
318                        ));
319                    }
320                }
321
322                if let Some((
323                    ori_idx,
324                    g_width,
325                    g_depth,
326                    g_height,
327                    place_x,
328                    place_y,
329                    place_z,
330                    new_row_depth,
331                    new_layer_height,
332                )) = best_fit
333                {
334                    // Convert orientation index to rotation angles
335                    // For simplicity, we encode orientation in rotation_index
336                    let placement = Placement::new_3d(
337                        geom.id().clone(),
338                        instance,
339                        place_x,
340                        place_y,
341                        place_z,
342                        0.0, // Orientation is encoded via rotation_index
343                        0.0,
344                        0.0,
345                    )
346                    .with_rotation_index(ori_idx);
347
348                    placements.push(placement);
349                    total_placed_volume += geom.measure();
350                    if let Some(mass) = geom.mass() {
351                        total_placed_mass += mass;
352                    }
353
354                    // Update position for next item
355                    current_x = place_x + g_width + spacing;
356                    current_y = place_y;
357                    current_z = place_z;
358                    row_depth = new_row_depth.max(g_depth);
359                    layer_height = new_layer_height.max(g_height);
360                } else {
361                    result.unplaced.push(geom.id().clone());
362                }
363            }
364        }
365
366        result.placements = placements;
367        result.boundaries_used = 1;
368        result.utilization = total_placed_volume / boundary.measure();
369        result.computation_time_ms = start.elapsed_ms();
370
371        Ok(result)
372    }
373
374    /// Genetic Algorithm based packing optimization.
375    ///
376    /// Uses GA to optimize placement order and orientations, with layer-based
377    /// decoding for collision-free placements.
378    fn genetic_algorithm(
379        &self,
380        geometries: &[Geometry3D],
381        boundary: &Boundary3D,
382    ) -> Result<SolveResult<f64>> {
383        // Configure GA from solver config
384        let ga_config = GaConfig::default()
385            .with_population_size(self.config.population_size)
386            .with_max_generations(self.config.max_generations)
387            .with_crossover_rate(self.config.crossover_rate)
388            .with_mutation_rate(self.config.mutation_rate);
389
390        let result = run_ga_packing(
391            geometries,
392            boundary,
393            &self.config,
394            ga_config,
395            self.cancelled.clone(),
396        );
397
398        Ok(result)
399    }
400
401    /// BRKGA (Biased Random-Key Genetic Algorithm) based packing optimization.
402    ///
403    /// Uses random-key encoding and biased crossover for robust optimization.
404    fn brkga(&self, geometries: &[Geometry3D], boundary: &Boundary3D) -> Result<SolveResult<f64>> {
405        // Configure BRKGA with reasonable defaults
406        let brkga_config = BrkgaConfig::default()
407            .with_population_size(50)
408            .with_max_generations(100)
409            .with_elite_fraction(0.2)
410            .with_mutant_fraction(0.15)
411            .with_elite_bias(0.7);
412
413        let result = run_brkga_packing(
414            geometries,
415            boundary,
416            &self.config,
417            brkga_config,
418            self.cancelled.clone(),
419        );
420
421        Ok(result)
422    }
423
424    /// Simulated Annealing based packing optimization.
425    ///
426    /// Uses neighborhood operators to explore solution space with temperature-based
427    /// acceptance probability.
428    fn simulated_annealing(
429        &self,
430        geometries: &[Geometry3D],
431        boundary: &Boundary3D,
432    ) -> Result<SolveResult<f64>> {
433        // Configure SA with reasonable defaults
434        let sa_config = SaConfig::default()
435            .with_initial_temp(100.0)
436            .with_final_temp(0.1)
437            .with_cooling_rate(0.95)
438            .with_iterations_per_temp(50)
439            .with_max_iterations(10000);
440
441        let result = run_sa_packing(
442            geometries,
443            boundary,
444            &self.config,
445            sa_config,
446            self.cancelled.clone(),
447        );
448
449        Ok(result)
450    }
451
452    /// Extreme Point heuristic-based packing.
453    ///
454    /// Places boxes at extreme points (positions touching at least two surfaces).
455    /// More efficient than layer-based packing for many scenarios.
456    fn extreme_point(
457        &self,
458        geometries: &[Geometry3D],
459        boundary: &Boundary3D,
460    ) -> Result<SolveResult<f64>> {
461        let start = Timer::now();
462
463        let (ep_placements, utilization) = run_ep_packing(
464            geometries,
465            boundary,
466            self.config.margin,
467            self.config.spacing,
468            boundary.max_mass(),
469        );
470
471        // Convert EP placements to Placement structs. The orientation index must be
472        // preserved via `rotation_index` so the response can report the actual box
473        // footprint; dropping it made every EP placement report the identity
474        // orientation, which read as out-of-bounds for rotated boxes downstream.
475        let mut placements = Vec::new();
476        for (id, instance, position, orientation) in ep_placements {
477            let placement = Placement::new_3d(
478                id, instance, position.x, position.y, position.z, 0.0, // rotation_x
479                0.0, // rotation_y
480                0.0, // rotation_z (orientation encoded via rotation_index)
481            )
482            .with_rotation_index(orientation);
483            placements.push(placement);
484        }
485
486        // Collect unplaced items
487        let mut placed_ids: std::collections::HashSet<(String, usize)> =
488            std::collections::HashSet::new();
489        for p in &placements {
490            placed_ids.insert((p.geometry_id.clone(), p.instance));
491        }
492
493        let mut unplaced = Vec::new();
494        for geom in geometries {
495            for instance in 0..geom.quantity() {
496                if !placed_ids.contains(&(geom.id().clone(), instance)) {
497                    unplaced.push(geom.id().clone());
498                }
499            }
500        }
501
502        let mut result = SolveResult::new();
503        result.placements = placements;
504        result.boundaries_used = 1;
505        result.utilization = utilization;
506        result.unplaced = unplaced;
507        result.computation_time_ms = start.elapsed_ms();
508        result.strategy = Some("ExtremePoint".to_string());
509
510        Ok(result)
511    }
512
513    /// Layer packing with progress callback.
514    fn layer_packing_with_progress(
515        &self,
516        geometries: &[Geometry3D],
517        boundary: &Boundary3D,
518        callback: &ProgressCallback,
519    ) -> Result<SolveResult<f64>> {
520        let start = Timer::now();
521        let mut result = SolveResult::new();
522        let mut placements = Vec::new();
523
524        let margin = self.config.margin;
525        let spacing = self.config.spacing;
526
527        let bound_max_x = boundary.width() - margin;
528        let bound_max_y = boundary.depth() - margin;
529        let bound_max_z = boundary.height() - margin;
530
531        let mut current_x = margin;
532        let mut current_y = margin;
533        let mut current_z = margin;
534        let mut row_depth = 0.0_f64;
535        let mut layer_height = 0.0_f64;
536
537        let mut total_placed_volume = 0.0;
538        let mut total_placed_mass = 0.0;
539
540        // Count total pieces for progress
541        let total_pieces: usize = geometries.iter().map(|g| g.quantity()).sum();
542        let mut placed_count = 0usize;
543
544        // Initial progress callback
545        callback(
546            ProgressInfo::new()
547                .with_phase("Layer Packing")
548                .with_items(0, total_pieces)
549                .with_elapsed(0),
550        );
551
552        for geom in geometries {
553            geom.validate()?;
554
555            for instance in 0..geom.quantity() {
556                if self.cancelled.load(Ordering::Relaxed) {
557                    result.computation_time_ms = start.elapsed_ms();
558                    callback(
559                        ProgressInfo::new()
560                            .with_phase("Cancelled")
561                            .with_items(placed_count, total_pieces)
562                            .with_elapsed(result.computation_time_ms)
563                            .finished(),
564                    );
565                    return Ok(result);
566                }
567
568                // Check time limit (0 = unlimited)
569                if self.config.time_limit_ms > 0 && start.elapsed_ms() >= self.config.time_limit_ms
570                {
571                    result.boundaries_used = if placements.is_empty() { 0 } else { 1 };
572                    result.utilization = total_placed_volume / boundary.measure();
573                    result.computation_time_ms = start.elapsed_ms();
574                    result.placements = placements;
575                    callback(
576                        ProgressInfo::new()
577                            .with_phase("Time Limit Reached")
578                            .with_items(placed_count, total_pieces)
579                            .with_elapsed(result.computation_time_ms)
580                            .finished(),
581                    );
582                    return Ok(result);
583                }
584
585                // Check mass constraint
586                if let (Some(max_mass), Some(item_mass)) = (boundary.max_mass(), geom.mass()) {
587                    if total_placed_mass + item_mass > max_mass {
588                        result.unplaced.push(geom.id().clone());
589                        continue;
590                    }
591                }
592
593                // Try all allowed orientations to find the best fit
594                let orientations = geom.allowed_orientations();
595                let mut best_fit: BestFit3D = None;
596
597                for (ori_idx, _) in orientations.iter().enumerate() {
598                    let dims = geom.dimensions_for_orientation(ori_idx);
599                    let g_width = dims.x;
600                    let g_depth = dims.y;
601                    let g_height = dims.z;
602
603                    let mut try_x = current_x;
604                    let mut try_y = current_y;
605                    let mut try_z = current_z;
606                    let mut try_row_depth = row_depth;
607                    let mut try_layer_height = layer_height;
608
609                    if try_x + g_width > bound_max_x {
610                        try_x = margin;
611                        try_y += row_depth + spacing;
612                        try_row_depth = 0.0;
613                    }
614
615                    if try_y + g_depth > bound_max_y {
616                        try_x = margin;
617                        try_y = margin;
618                        try_z += layer_height + spacing;
619                        try_row_depth = 0.0;
620                        try_layer_height = 0.0;
621                    }
622
623                    if try_x + g_width > bound_max_x
624                        || try_y + g_depth > bound_max_y
625                        || try_z + g_height > bound_max_z
626                    {
627                        continue;
628                    }
629
630                    let score = try_z * 1000000.0 + try_y * 1000.0 + try_x + g_height * 0.1;
631
632                    let is_better = match &best_fit {
633                        None => true,
634                        Some((_, _, _, bg_height, bx, by, bz, _, _)) => {
635                            let best_score = bz * 1000000.0 + by * 1000.0 + bx + bg_height * 0.1;
636                            score < best_score
637                        }
638                    };
639
640                    if is_better {
641                        best_fit = Some((
642                            ori_idx,
643                            g_width,
644                            g_depth,
645                            g_height,
646                            try_x,
647                            try_y,
648                            try_z,
649                            try_row_depth,
650                            try_layer_height,
651                        ));
652                    }
653                }
654
655                if let Some((
656                    ori_idx,
657                    g_width,
658                    g_depth,
659                    g_height,
660                    place_x,
661                    place_y,
662                    place_z,
663                    new_row_depth,
664                    new_layer_height,
665                )) = best_fit
666                {
667                    let placement = Placement::new_3d(
668                        geom.id().clone(),
669                        instance,
670                        place_x,
671                        place_y,
672                        place_z,
673                        0.0,
674                        0.0,
675                        0.0,
676                    )
677                    .with_rotation_index(ori_idx);
678
679                    placements.push(placement);
680                    total_placed_volume += geom.measure();
681                    if let Some(mass) = geom.mass() {
682                        total_placed_mass += mass;
683                    }
684                    placed_count += 1;
685
686                    current_x = place_x + g_width + spacing;
687                    current_y = place_y;
688                    current_z = place_z;
689                    row_depth = new_row_depth.max(g_depth);
690                    layer_height = new_layer_height.max(g_height);
691
692                    // Progress callback every piece
693                    callback(
694                        ProgressInfo::new()
695                            .with_phase("Layer Packing")
696                            .with_items(placed_count, total_pieces)
697                            .with_utilization(total_placed_volume / boundary.measure())
698                            .with_elapsed(start.elapsed_ms()),
699                    );
700                } else {
701                    result.unplaced.push(geom.id().clone());
702                }
703            }
704        }
705
706        result.placements = placements;
707        result.boundaries_used = 1;
708        result.utilization = total_placed_volume / boundary.measure();
709        result.computation_time_ms = start.elapsed_ms();
710
711        // Final progress callback
712        callback(
713            ProgressInfo::new()
714                .with_phase("Complete")
715                .with_items(placed_count, total_pieces)
716                .with_utilization(result.utilization)
717                .with_elapsed(result.computation_time_ms)
718                .finished(),
719        );
720
721        Ok(result)
722    }
723}
724
725impl Solver for Packer3D {
726    type Geometry = Geometry3D;
727    type Boundary = Boundary3D;
728    type Scalar = f64;
729
730    fn solve(
731        &self,
732        geometries: &[Self::Geometry],
733        boundary: &Self::Boundary,
734    ) -> Result<SolveResult<f64>> {
735        boundary.validate()?;
736        u_nesting_core::geometry::ensure_unique_ids(geometries)?;
737
738        // Reset cancellation flag
739        self.cancelled.store(false, Ordering::Relaxed);
740
741        let mut result = self.run_strategy(geometries, boundary)?;
742
743        // Remove duplicate entries from unplaced list
744        result.deduplicate_unplaced();
745        // Authoritative instance-level request total (Σ quantity), recorded once
746        // at the top-level entry point where `geometries` is the full request.
747        result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
748
749        // Honor gravity/stability constraints, if requested, on the finished packing.
750        if boundary.has_gravity() || boundary.has_stability() {
751            self.enforce_support(&mut result, geometries, boundary);
752        }
753        Ok(result)
754    }
755
756    fn solve_with_progress(
757        &self,
758        geometries: &[Self::Geometry],
759        boundary: &Self::Boundary,
760        callback: ProgressCallback,
761    ) -> Result<SolveResult<f64>> {
762        boundary.validate()?;
763        u_nesting_core::geometry::ensure_unique_ids(geometries)?;
764
765        // Reset cancellation flag
766        self.cancelled.store(false, Ordering::Relaxed);
767
768        let mut result = match self.config.strategy {
769            Strategy::BottomLeftFill => {
770                self.layer_packing_with_progress(geometries, boundary, &callback)?
771            }
772            // EP is a fast single pass; run the real heuristic (not layer packing) and
773            // skip incremental progress rather than silently substituting BLF.
774            Strategy::ExtremePoint => self.extreme_point(geometries, boundary)?,
775            // The other strategies have no progress reporting yet: run the
776            // strategy that was asked for, without progress, rather than a
777            // different one that reports it.
778            _ => self.run_strategy(geometries, boundary)?,
779        };
780
781        // Remove duplicate entries from unplaced list
782        result.deduplicate_unplaced();
783        // Authoritative instance-level request total (Σ quantity), recorded once
784        // at the top-level entry point where `geometries` is the full request.
785        result.total_requested = geometries.iter().map(|g| g.quantity()).sum();
786
787        // Honor gravity/stability constraints, if requested, on the finished packing.
788        if boundary.has_gravity() || boundary.has_stability() {
789            self.enforce_support(&mut result, geometries, boundary);
790        }
791        Ok(result)
792    }
793
794    fn cancel(&self) {
795        self.cancelled.store(true, Ordering::Relaxed);
796    }
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802
803    #[test]
804    fn a_geometry_id_given_twice_is_refused_by_every_entry_point() {
805        let geometries = [
806            Geometry3D::new("B", 10.0, 10.0, 10.0),
807            Geometry3D::new("B", 50.0, 50.0, 50.0),
808        ];
809        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
810        let packer = Packer3D::default_config();
811        let errors = [
812            packer.solve(&geometries, &boundary).expect_err("solve"),
813            packer
814                .solve_with_progress(&geometries, &boundary, Box::new(|_| {}))
815                .expect_err("solve_with_progress"),
816        ];
817        for err in errors {
818            let msg = err.to_string();
819            assert!(msg.contains("'B'"), "{msg}");
820            assert!(msg.contains("positions 0 and 1"), "{msg}");
821        }
822    }
823
824    #[test]
825    fn test_simple_packing() {
826        let geometries = vec![
827            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(3),
828            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
829        ];
830
831        let boundary = Boundary3D::new(100.0, 80.0, 50.0);
832        let packer = Packer3D::default_config();
833
834        let result = packer.solve(&geometries, &boundary).unwrap();
835
836        assert!(result.utilization > 0.0);
837        assert!(result.placements.len() <= 5);
838    }
839
840    #[test]
841    fn test_mass_constraint() {
842        let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0)
843            .with_quantity(10)
844            .with_mass(100.0)];
845
846        let boundary = Boundary3D::new(100.0, 80.0, 50.0).with_max_mass(350.0);
847
848        let packer = Packer3D::default_config();
849        let result = packer.solve(&geometries, &boundary).unwrap();
850
851        // Should only place 3 boxes (300 mass) due to 350 mass limit
852        assert!(result.placements.len() <= 3);
853    }
854
855    #[test]
856    fn test_placement_within_bounds() {
857        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
858
859        let boundary = Boundary3D::new(50.0, 50.0, 50.0);
860        let config = Config::default().with_margin(5.0).with_spacing(2.0);
861        let packer = Packer3D::new(config);
862
863        let result = packer.solve(&geometries, &boundary).unwrap();
864
865        // All boxes should be placed
866        assert_eq!(result.placements.len(), 4);
867        assert!(result.unplaced.is_empty());
868
869        // Verify placements are within bounds (with margin)
870        for p in &result.placements {
871            assert!(p.position[0] >= 5.0);
872            assert!(p.position[1] >= 5.0);
873            assert!(p.position[2] >= 5.0);
874        }
875    }
876
877    #[test]
878    fn test_ga_strategy_basic() {
879        let geometries = vec![
880            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
881            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
882        ];
883
884        let boundary = Boundary3D::new(100.0, 80.0, 50.0);
885        let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
886        let packer = Packer3D::new(config);
887
888        let result = packer.solve(&geometries, &boundary).unwrap();
889
890        // GA should place items and achieve positive utilization
891        assert!(result.utilization > 0.0);
892        assert!(!result.placements.is_empty());
893    }
894
895    #[test]
896    fn test_ga_strategy_all_placed() {
897        // Small number of boxes that should all fit
898        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
899
900        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
901        let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
902        let packer = Packer3D::new(config);
903
904        let result = packer.solve(&geometries, &boundary).unwrap();
905
906        // All 4 boxes should be placed
907        assert_eq!(result.placements.len(), 4);
908        assert!(result.unplaced.is_empty());
909    }
910
911    #[test]
912    #[cfg(feature = "serde")]
913    fn test_3d_response_orientation_in_bounds() {
914        use crate::build_pack3d_response;
915        use crate::geometry::OrientationConstraint;
916
917        // A tall box in a flat container can only be placed rotated. The response must
918        // report the orientation that was actually used, so reconstructing the box
919        // footprint from the reported orientation label keeps it inside the boundary.
920        // Pre-fix, EP/GA/BRKGA dropped the orientation and reported "xyz" (identity),
921        // making rotated placements read as out-of-bounds (the 0.3.3 "oob" reports).
922        let (bw, bd, bh) = (100.0_f64, 100.0_f64, 30.0_f64);
923        let boundary = Boundary3D::new(bw, bd, bh);
924
925        for strategy in [
926            Strategy::ExtremePoint,
927            Strategy::GeneticAlgorithm,
928            Strategy::Brkga,
929        ] {
930            let geometries = vec![Geometry3D::new("tall", 25.0, 25.0, 80.0)
931                .with_quantity(3)
932                .with_orientation(OrientationConstraint::Any)];
933            let config = Config::default().with_strategy(strategy);
934            let packer = Packer3D::new(config);
935            let result = packer.solve(&geometries, &boundary).unwrap();
936            let response = build_pack3d_response(&result, &geometries);
937
938            // EP is deterministic and must place at least one box (only fits rotated).
939            if strategy == Strategy::ExtremePoint {
940                assert!(
941                    !response.placements.is_empty(),
942                    "EP should place the tall box by rotating it into the flat container"
943                );
944            }
945
946            let base = geometries[0].dimensions_for_orientation(0); // unrotated dims
947            for p in &response.placements {
948                let axes: Vec<usize> = p
949                    .orientation
950                    .chars()
951                    .map(|c| match c {
952                        'x' => 0,
953                        'y' => 1,
954                        'z' => 2,
955                        _ => panic!("unexpected orientation label '{}'", p.orientation),
956                    })
957                    .collect();
958                let (dx, dy, dz) = (base[axes[0]], base[axes[1]], base[axes[2]]);
959                let e = 1e-6;
960                assert!(
961                    p.x + dx <= bw + e && p.y + dy <= bd + e && p.z + dz <= bh + e,
962                    "{:?} {}#{} out of bounds for reported orientation '{}': \
963                     pos({:.1},{:.1},{:.1}) dims({dx:.1},{dy:.1},{dz:.1}) boundary({bw},{bd},{bh})",
964                    strategy,
965                    p.geometry_id,
966                    p.instance,
967                    p.orientation,
968                    p.x,
969                    p.y,
970                    p.z,
971                );
972            }
973        }
974    }
975
976    #[test]
977    fn test_ga_strategy_with_orientations() {
978        use crate::geometry::OrientationConstraint;
979
980        // Box that fits better when rotated
981        let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
982            .with_quantity(2)
983            .with_orientation(OrientationConstraint::Any)];
984
985        // Container where orientation matters
986        let boundary = Boundary3D::new(60.0, 60.0, 60.0);
987        let config = Config::default().with_strategy(Strategy::GeneticAlgorithm);
988        let packer = Packer3D::new(config);
989
990        let result = packer.solve(&geometries, &boundary).unwrap();
991
992        // GA should find a way to place both boxes
993        assert_eq!(result.placements.len(), 2);
994    }
995
996    #[test]
997    fn test_brkga_strategy_basic() {
998        let geometries = vec![
999            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
1000            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
1001        ];
1002
1003        let boundary = Boundary3D::new(100.0, 80.0, 50.0);
1004        let config = Config::default().with_strategy(Strategy::Brkga);
1005        let packer = Packer3D::new(config);
1006
1007        let result = packer.solve(&geometries, &boundary).unwrap();
1008
1009        // BRKGA should place items and achieve positive utilization
1010        assert!(result.utilization > 0.0);
1011        assert!(!result.placements.is_empty());
1012        assert_eq!(result.strategy, Some("BRKGA".to_string()));
1013    }
1014
1015    #[test]
1016    fn test_brkga_strategy_all_placed() {
1017        // Small number of boxes that should all fit
1018        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
1019
1020        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1021        let config = Config::default().with_strategy(Strategy::Brkga);
1022        let packer = Packer3D::new(config);
1023
1024        let result = packer.solve(&geometries, &boundary).unwrap();
1025
1026        // All 4 boxes should be placed
1027        assert_eq!(result.placements.len(), 4);
1028        assert!(result.unplaced.is_empty());
1029    }
1030
1031    #[test]
1032    fn test_ep_strategy_basic() {
1033        let geometries = vec![
1034            Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(2),
1035            Geometry3D::new("B2", 15.0, 15.0, 15.0).with_quantity(2),
1036        ];
1037
1038        let boundary = Boundary3D::new(100.0, 80.0, 50.0);
1039        let config = Config::default().with_strategy(Strategy::ExtremePoint);
1040        let packer = Packer3D::new(config);
1041
1042        let result = packer.solve(&geometries, &boundary).unwrap();
1043
1044        // EP should place items and achieve positive utilization
1045        assert!(result.utilization > 0.0);
1046        assert!(!result.placements.is_empty());
1047        assert_eq!(result.strategy, Some("ExtremePoint".to_string()));
1048    }
1049
1050    #[test]
1051    fn test_ep_strategy_all_placed() {
1052        // Small number of boxes that should all fit
1053        let geometries = vec![Geometry3D::new("B1", 10.0, 10.0, 10.0).with_quantity(4)];
1054
1055        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1056        let config = Config::default().with_strategy(Strategy::ExtremePoint);
1057        let packer = Packer3D::new(config);
1058
1059        let result = packer.solve(&geometries, &boundary).unwrap();
1060
1061        // All 4 boxes should be placed
1062        assert_eq!(result.placements.len(), 4);
1063        assert!(result.unplaced.is_empty());
1064    }
1065
1066    #[test]
1067    fn test_ep_strategy_with_margin() {
1068        let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(4)];
1069
1070        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1071        let config = Config::default()
1072            .with_strategy(Strategy::ExtremePoint)
1073            .with_margin(5.0);
1074        let packer = Packer3D::new(config);
1075
1076        let result = packer.solve(&geometries, &boundary).unwrap();
1077
1078        // Verify placements start at margin
1079        for p in &result.placements {
1080            assert!(p.position[0] >= 4.9);
1081            assert!(p.position[1] >= 4.9);
1082            assert!(p.position[2] >= 4.9);
1083        }
1084    }
1085
1086    #[test]
1087    fn test_ep_strategy_with_orientations() {
1088        use crate::geometry::OrientationConstraint;
1089
1090        // Long box that benefits from rotation
1091        let geometries = vec![Geometry3D::new("B1", 80.0, 10.0, 10.0)
1092            .with_quantity(2)
1093            .with_orientation(OrientationConstraint::Any)];
1094
1095        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1096        let config = Config::default().with_strategy(Strategy::ExtremePoint);
1097        let packer = Packer3D::new(config);
1098
1099        let result = packer.solve(&geometries, &boundary).unwrap();
1100
1101        // EP should find a way to place both boxes
1102        assert_eq!(result.placements.len(), 2);
1103    }
1104
1105    #[test]
1106    fn test_ep_strategy_perfect_fill_via_solve() {
1107        // End-to-end through Packer3D::solve (the path FFI/WASM use): eight 50-cubes
1108        // tile a 100^3 container exactly. EP must place all 8. Guards the under-packing
1109        // regression at the dispatch level, not just run_ep_packing.
1110        let geometries = vec![Geometry3D::new("cube", 50.0, 50.0, 50.0).with_quantity(8)];
1111        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1112        let packer = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint));
1113
1114        let result = packer.solve(&geometries, &boundary).unwrap();
1115
1116        assert_eq!(result.placements.len(), 8, "EP must place all 8 cubes");
1117        assert!(result.unplaced.is_empty());
1118    }
1119
1120    #[test]
1121    fn test_ep_at_least_as_good_as_blf() {
1122        // A correct Extreme-Point heuristic never places fewer boxes than the simpler
1123        // layer (BLF) strategy on the same instance. Before the fix EP stalled far below
1124        // BLF (e.g. 4 vs 13 on the mixed-box set). Covers several shapes.
1125        let boundary = Boundary3D::new(85.0, 85.0, 80.0);
1126        let cases: [(&str, f64, f64, f64, usize); 3] = [
1127            ("big", 40.0, 40.0, 40.0, 4),
1128            ("mid", 30.0, 20.0, 25.0, 6),
1129            ("small", 15.0, 15.0, 30.0, 8),
1130        ];
1131        let geometries: Vec<Geometry3D> = cases
1132            .iter()
1133            .map(|(id, w, d, h, q)| Geometry3D::new(*id, *w, *d, *h).with_quantity(*q))
1134            .collect();
1135
1136        let blf = Packer3D::new(Config::default().with_strategy(Strategy::BottomLeftFill))
1137            .solve(&geometries, &boundary)
1138            .unwrap();
1139        let ep = Packer3D::new(Config::default().with_strategy(Strategy::ExtremePoint))
1140            .solve(&geometries, &boundary)
1141            .unwrap();
1142
1143        assert!(
1144            ep.placements.len() >= blf.placements.len(),
1145            "EP placed {} but BLF placed {} — EP must not regress below BLF",
1146            ep.placements.len(),
1147            blf.placements.len()
1148        );
1149    }
1150
1151    /// Builds a result whose second box floats above the floor with nothing beneath it.
1152    fn floating_pair() -> (Vec<Geometry3D>, SolveResult<f64>) {
1153        let geometries = vec![
1154            Geometry3D::new("a", 20.0, 20.0, 20.0),
1155            Geometry3D::new("b", 20.0, 20.0, 20.0),
1156        ];
1157        let mut result = SolveResult::new();
1158        result.placements.push(
1159            Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1160                .with_rotation_index(0),
1161        );
1162        // "b" hovers at z=50 — no floor contact, no box below.
1163        result.placements.push(
1164            Placement::new_3d("b".to_string(), 0, 0.0, 0.0, 50.0, 0.0, 0.0, 0.0)
1165                .with_rotation_index(0),
1166        );
1167        (geometries, result)
1168    }
1169
1170    #[test]
1171    fn test_gravity_removes_floating_box() {
1172        let (geometries, mut result) = floating_pair();
1173        let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1174        let packer = Packer3D::default_config();
1175
1176        packer.enforce_support(&mut result, &geometries, &boundary);
1177
1178        let ids: Vec<&str> = result
1179            .placements
1180            .iter()
1181            .map(|p| p.geometry_id.as_str())
1182            .collect();
1183        assert_eq!(ids, vec!["a"], "floating box must be dropped under gravity");
1184        assert!(result.unplaced.iter().any(|id| id == "b"));
1185    }
1186
1187    #[test]
1188    fn test_no_constraint_keeps_floating_box() {
1189        // Without gravity/stability the solver never calls enforce_support, so a result
1190        // with a floating box is returned untouched. Verify enforce_support is the only
1191        // thing that would remove it — i.e. solve() leaves such input alone.
1192        let (geometries, result) = floating_pair();
1193        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
1194        assert!(!boundary.has_gravity() && !boundary.has_stability());
1195        // The pair was hand-built (not via solve); just assert the boundary flags gate it.
1196        assert_eq!(result.placements.len(), 2);
1197        let _ = geometries;
1198    }
1199
1200    #[test]
1201    fn test_gravity_keeps_floor_boxes_with_margin() {
1202        // With a wall margin the pack starts boxes at z = margin, which the support
1203        // analysis must treat as the floor. If it checks against z = 0 instead, every
1204        // bottom-layer box reads as floating and the whole pack gets dropped. End-to-end
1205        // through solve() with margin > 0 and gravity on.
1206        let geometries = vec![
1207            Geometry3D::new("big", 40.0, 40.0, 40.0).with_quantity(4),
1208            Geometry3D::new("mid", 30.0, 20.0, 25.0).with_quantity(6),
1209        ];
1210        let boundary = Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true);
1211        let packer = Packer3D::new(
1212            Config::default()
1213                .with_margin(5.0)
1214                .with_strategy(Strategy::BottomLeftFill),
1215        );
1216
1217        let result = packer.solve(&geometries, &boundary).unwrap();
1218
1219        assert!(
1220            !result.placements.is_empty(),
1221            "margin>0 + gravity must not drop floor-resting boxes"
1222        );
1223    }
1224
1225    #[test]
1226    fn test_stability_drops_undersupported_box() {
1227        // "b" rests on "a" but overlaps only a thin sliver of its top face (well under
1228        // the 70% base-support threshold). Gravity alone keeps it (it does touch below);
1229        // stability drops it.
1230        let geometries = vec![
1231            Geometry3D::new("a", 40.0, 40.0, 20.0),
1232            Geometry3D::new("b", 40.0, 40.0, 20.0),
1233        ];
1234        let make = || {
1235            let mut r = SolveResult::new();
1236            r.placements.push(
1237                Placement::new_3d("a".to_string(), 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
1238                    .with_rotation_index(0),
1239            );
1240            // shifted so only a 5x40 strip (≈12.5%) of b's base sits on a's top
1241            r.placements.push(
1242                Placement::new_3d("b".to_string(), 0, 35.0, 0.0, 20.0, 0.0, 0.0, 0.0)
1243                    .with_rotation_index(0),
1244            );
1245            r
1246        };
1247        let packer = Packer3D::default_config();
1248
1249        let mut grav = make();
1250        packer.enforce_support(
1251            &mut grav,
1252            &geometries,
1253            &Boundary3D::new(100.0, 100.0, 100.0).with_gravity(true),
1254        );
1255        assert_eq!(
1256            grav.placements.len(),
1257            2,
1258            "gravity keeps a box that touches below"
1259        );
1260
1261        let mut stab = make();
1262        packer.enforce_support(
1263            &mut stab,
1264            &geometries,
1265            &Boundary3D::new(100.0, 100.0, 100.0).with_stability(true),
1266        );
1267        assert!(
1268            stab.placements.iter().all(|p| p.geometry_id == "a"),
1269            "stability drops the under-supported box"
1270        );
1271    }
1272
1273    #[test]
1274    fn test_layer_packing_orientation_optimization() {
1275        use crate::geometry::OrientationConstraint;
1276
1277        // A box 50x10x10 that won't fit in 45 width without rotation
1278        // But at orientation (1,0,2) it becomes 10x50x10, width=10, which fits
1279        let geometries = vec![Geometry3D::new("B1", 50.0, 10.0, 10.0)
1280            .with_quantity(2)
1281            .with_orientation(OrientationConstraint::Any)];
1282
1283        // Narrow container: width=45, depth=80, height=80
1284        let boundary = Boundary3D::new(45.0, 80.0, 80.0);
1285        let config = Config::default().with_strategy(Strategy::BottomLeftFill);
1286        let packer = Packer3D::new(config);
1287
1288        let result = packer.solve(&geometries, &boundary).unwrap();
1289
1290        // Both boxes should be placed via orientation change
1291        assert_eq!(
1292            result.placements.len(),
1293            2,
1294            "Both boxes should be placed by using rotation"
1295        );
1296        assert!(result.unplaced.is_empty());
1297
1298        // Verify orientation index is set for placements
1299        for p in &result.placements {
1300            assert!(
1301                p.rotation_index.is_some(),
1302                "Placement should have rotation_index set"
1303            );
1304        }
1305    }
1306
1307    #[test]
1308    fn test_layer_packing_selects_best_orientation() {
1309        use crate::geometry::OrientationConstraint;
1310
1311        // Box 30x20x10 in container 35x50x100
1312        // Original orientation (30x20x10): fits in row, leaves 5 spare width
1313        // Rotated (20x30x10): fits but uses more depth
1314        // Best: original orientation to minimize vertical space usage
1315        let geometries = vec![Geometry3D::new("B1", 30.0, 20.0, 10.0)
1316            .with_quantity(1)
1317            .with_orientation(OrientationConstraint::Any)];
1318
1319        let boundary = Boundary3D::new(35.0, 50.0, 100.0);
1320        let packer = Packer3D::default_config();
1321
1322        let result = packer.solve(&geometries, &boundary).unwrap();
1323
1324        assert_eq!(result.placements.len(), 1);
1325        assert!(result.unplaced.is_empty());
1326    }
1327}