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