u_nesting_d3/
extreme_point.rs

1//! Extreme Point heuristic for 3D bin packing.
2//!
3//! This module implements the Extreme Point (EP) heuristic for 3D bin packing,
4//! which is more efficient than simple layer-based packing for many real-world
5//! scenarios.
6//!
7//! # Algorithm Overview
8//!
9//! Extreme Points are positions where a new box could be placed touching at least
10//! two surfaces (walls or other boxes). When a box is placed, it generates new
11//! extreme points at its corners and edges.
12//!
13//! # References
14//!
15//! - Crainic, T. G., Perboli, G., & Tadei, R. (2008). Extreme point-based heuristics
16//!   for three-dimensional bin packing.
17
18use crate::boundary::Boundary3D;
19use crate::geometry::Geometry3D;
20use nalgebra::Vector3;
21use std::cmp::Ordering;
22use std::collections::BinaryHeap;
23use u_nesting_core::geometry::{Boundary, Geometry};
24
25/// A 3D point representing a potential placement position.
26#[derive(Debug, Clone, Copy)]
27pub struct ExtremePoint {
28    /// Position (x, y, z).
29    pub position: Vector3<f64>,
30    /// The residual space in x direction from this point.
31    pub residual_x: f64,
32    /// The residual space in y direction from this point.
33    pub residual_y: f64,
34    /// The residual space in z direction from this point.
35    pub residual_z: f64,
36}
37
38impl ExtremePoint {
39    /// Creates a new extreme point.
40    pub fn new(x: f64, y: f64, z: f64, res_x: f64, res_y: f64, res_z: f64) -> Self {
41        Self {
42            position: Vector3::new(x, y, z),
43            residual_x: res_x,
44            residual_y: res_y,
45            residual_z: res_z,
46        }
47    }
48
49    /// Returns the position as a tuple.
50    pub fn pos(&self) -> (f64, f64, f64) {
51        (self.position.x, self.position.y, self.position.z)
52    }
53
54    /// Checks if a box with given dimensions fits at this point.
55    pub fn fits(&self, width: f64, depth: f64, height: f64) -> bool {
56        width <= self.residual_x + 1e-9
57            && depth <= self.residual_y + 1e-9
58            && height <= self.residual_z + 1e-9
59    }
60}
61
62impl PartialEq for ExtremePoint {
63    fn eq(&self, other: &Self) -> bool {
64        (self.position - other.position).norm() < 1e-9
65    }
66}
67
68impl Eq for ExtremePoint {}
69
70/// Wrapper for BinaryHeap ordering (min-heap by z, then y, then x).
71#[derive(Debug, Clone)]
72struct OrderedEP(ExtremePoint);
73
74impl PartialEq for OrderedEP {
75    fn eq(&self, other: &Self) -> bool {
76        self.0 == other.0
77    }
78}
79
80impl Eq for OrderedEP {}
81
82impl PartialOrd for OrderedEP {
83    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
84        Some(self.cmp(other))
85    }
86}
87
88impl Ord for OrderedEP {
89    fn cmp(&self, other: &Self) -> Ordering {
90        // Min-heap: prefer lower z, then lower y, then lower x (reversed for BinaryHeap)
91        let z_cmp = other
92            .0
93            .position
94            .z
95            .partial_cmp(&self.0.position.z)
96            .unwrap_or(Ordering::Equal);
97        if z_cmp != Ordering::Equal {
98            return z_cmp;
99        }
100
101        let y_cmp = other
102            .0
103            .position
104            .y
105            .partial_cmp(&self.0.position.y)
106            .unwrap_or(Ordering::Equal);
107        if y_cmp != Ordering::Equal {
108            return y_cmp;
109        }
110
111        other
112            .0
113            .position
114            .x
115            .partial_cmp(&self.0.position.x)
116            .unwrap_or(Ordering::Equal)
117    }
118}
119
120/// A placed box in the container.
121#[derive(Debug, Clone)]
122pub struct PlacedBox {
123    /// Geometry ID.
124    pub id: String,
125    /// Instance number.
126    pub instance: usize,
127    /// Position (min corner).
128    pub position: Vector3<f64>,
129    /// Dimensions after orientation applied.
130    pub dimensions: Vector3<f64>,
131    /// Mass of the box.
132    pub mass: Option<f64>,
133}
134
135impl PlacedBox {
136    /// Returns the max corner of the box.
137    pub fn max_corner(&self) -> Vector3<f64> {
138        self.position + self.dimensions
139    }
140
141    /// Checks if this box overlaps with another box.
142    pub fn overlaps(&self, other: &PlacedBox) -> bool {
143        let self_max = self.max_corner();
144        let other_max = other.max_corner();
145
146        // Check for non-overlap in each dimension
147        let no_overlap_x =
148            self.position.x >= other_max.x - 1e-9 || other.position.x >= self_max.x - 1e-9;
149        let no_overlap_y =
150            self.position.y >= other_max.y - 1e-9 || other.position.y >= self_max.y - 1e-9;
151        let no_overlap_z =
152            self.position.z >= other_max.z - 1e-9 || other.position.z >= self_max.z - 1e-9;
153
154        !(no_overlap_x || no_overlap_y || no_overlap_z)
155    }
156}
157
158/// Extreme Point Set manager.
159pub struct ExtremePointSet {
160    /// Priority queue of extreme points (min-heap by z, y, x).
161    points: BinaryHeap<OrderedEP>,
162    /// Container dimensions.
163    container: Vector3<f64>,
164    /// Placed boxes.
165    placed: Vec<PlacedBox>,
166    /// Spacing between boxes.
167    spacing: f64,
168    /// Margin from container walls.
169    margin: f64,
170}
171
172impl ExtremePointSet {
173    /// Creates a new extreme point set for a container.
174    pub fn new(boundary: &Boundary3D, margin: f64, spacing: f64) -> Self {
175        let container = Vector3::new(boundary.width(), boundary.depth(), boundary.height());
176
177        let mut eps = Self {
178            points: BinaryHeap::new(),
179            container,
180            placed: Vec::new(),
181            spacing,
182            margin,
183        };
184
185        // Initial extreme point at origin (with margin)
186        let initial_ep = ExtremePoint::new(
187            margin,
188            margin,
189            margin,
190            container.x - 2.0 * margin,
191            container.y - 2.0 * margin,
192            container.z - 2.0 * margin,
193        );
194        eps.points.push(OrderedEP(initial_ep));
195
196        eps
197    }
198
199    /// Returns the number of extreme points.
200    pub fn len(&self) -> usize {
201        self.points.len()
202    }
203
204    /// Returns true if empty.
205    pub fn is_empty(&self) -> bool {
206        self.points.is_empty()
207    }
208
209    /// Returns the number of placed boxes.
210    pub fn placed_count(&self) -> usize {
211        self.placed.len()
212    }
213
214    /// Returns total placed volume.
215    pub fn total_volume(&self) -> f64 {
216        self.placed
217            .iter()
218            .map(|b| b.dimensions.x * b.dimensions.y * b.dimensions.z)
219            .sum()
220    }
221
222    /// Returns total placed mass.
223    pub fn total_mass(&self) -> f64 {
224        self.placed.iter().filter_map(|b| b.mass).sum()
225    }
226
227    /// Returns the placed boxes.
228    pub fn placed_boxes(&self) -> &[PlacedBox] {
229        &self.placed
230    }
231
232    /// Tries to place a box, returns the placement position if successful.
233    pub fn try_place(
234        &mut self,
235        geom: &Geometry3D,
236        instance: usize,
237        orientation: usize,
238    ) -> Option<Vector3<f64>> {
239        let dims = geom.dimensions_for_orientation(orientation);
240        let width = dims.x + self.spacing;
241        let depth = dims.y + self.spacing;
242        let height = dims.z + self.spacing;
243
244        // Collect all current EPs
245        let mut candidates: Vec<ExtremePoint> = Vec::new();
246        while let Some(OrderedEP(ep)) = self.points.pop() {
247            candidates.push(ep);
248        }
249
250        // Find the best fitting EP
251        let mut best_ep_idx: Option<usize> = None;
252        for (idx, ep) in candidates.iter().enumerate() {
253            if ep.fits(width, height, depth) || ep.fits(width, depth, height) {
254                // Check if placement would overlap with existing boxes
255                let test_box = PlacedBox {
256                    id: String::new(),
257                    instance: 0,
258                    position: ep.position,
259                    dimensions: dims,
260                    mass: None,
261                };
262
263                let overlaps = self.placed.iter().any(|placed| test_box.overlaps(placed));
264                if !overlaps {
265                    best_ep_idx = Some(idx);
266                    break;
267                }
268            }
269        }
270
271        // Restore non-used EPs
272        let result = if let Some(idx) = best_ep_idx {
273            let chosen_ep = candidates.remove(idx);
274
275            // Place the box
276            let placed_box = PlacedBox {
277                id: geom.id().clone(),
278                instance,
279                position: chosen_ep.position,
280                dimensions: dims,
281                mass: geom.mass(),
282            };
283
284            // Generate new extreme points
285            self.generate_new_eps(&placed_box);
286
287            let position = chosen_ep.position;
288            self.placed.push(placed_box);
289
290            Some(position)
291        } else {
292            None
293        };
294
295        // Return remaining candidates to the heap
296        for ep in candidates {
297            self.points.push(OrderedEP(ep));
298        }
299
300        result
301    }
302
303    /// Generates new extreme points after placing a box.
304    fn generate_new_eps(&mut self, placed: &PlacedBox) {
305        let box_max = placed.max_corner();
306        let container_max = self.container - Vector3::new(self.margin, self.margin, self.margin);
307
308        // EP1: Top-right-front of the box (x direction)
309        if box_max.x < container_max.x {
310            let res_x = container_max.x - box_max.x;
311            let res_y = self.compute_residual_y(box_max.x, placed.position.y, placed.position.z);
312            let res_z = self.compute_residual_z(box_max.x, placed.position.y, placed.position.z);
313
314            if res_x > 1e-9 && res_y > 1e-9 && res_z > 1e-9 {
315                let ep = ExtremePoint::new(
316                    box_max.x,
317                    placed.position.y,
318                    placed.position.z,
319                    res_x,
320                    res_y,
321                    res_z,
322                );
323                self.add_ep_if_valid(ep);
324            }
325        }
326
327        // EP2: Top-right-front of the box (y direction)
328        if box_max.y < container_max.y {
329            let res_x = self.compute_residual_x(placed.position.x, box_max.y, placed.position.z);
330            let res_y = container_max.y - box_max.y;
331            let res_z = self.compute_residual_z(placed.position.x, box_max.y, placed.position.z);
332
333            if res_x > 1e-9 && res_y > 1e-9 && res_z > 1e-9 {
334                let ep = ExtremePoint::new(
335                    placed.position.x,
336                    box_max.y,
337                    placed.position.z,
338                    res_x,
339                    res_y,
340                    res_z,
341                );
342                self.add_ep_if_valid(ep);
343            }
344        }
345
346        // EP3: Top of the box (z direction)
347        if box_max.z < container_max.z {
348            let res_x = self.compute_residual_x(placed.position.x, placed.position.y, box_max.z);
349            let res_y = self.compute_residual_y(placed.position.x, placed.position.y, box_max.z);
350            let res_z = container_max.z - box_max.z;
351
352            if res_x > 1e-9 && res_y > 1e-9 && res_z > 1e-9 {
353                let ep = ExtremePoint::new(
354                    placed.position.x,
355                    placed.position.y,
356                    box_max.z,
357                    res_x,
358                    res_y,
359                    res_z,
360                );
361                self.add_ep_if_valid(ep);
362            }
363        }
364    }
365
366    /// Adds an EP if it's valid and not dominated by existing EPs.
367    fn add_ep_if_valid(&mut self, ep: ExtremePoint) {
368        // Check bounds
369        let container_max = self.container - Vector3::new(self.margin, self.margin, self.margin);
370        if ep.position.x >= container_max.x - 1e-9
371            || ep.position.y >= container_max.y - 1e-9
372            || ep.position.z >= container_max.z - 1e-9
373        {
374            return;
375        }
376
377        // Check if position is inside any placed box
378        for placed in &self.placed {
379            let max = placed.max_corner();
380            if ep.position.x > placed.position.x - 1e-9
381                && ep.position.x < max.x + 1e-9
382                && ep.position.y > placed.position.y - 1e-9
383                && ep.position.y < max.y + 1e-9
384                && ep.position.z > placed.position.z - 1e-9
385                && ep.position.z < max.z + 1e-9
386            {
387                return;
388            }
389        }
390
391        self.points.push(OrderedEP(ep));
392    }
393
394    /// Computes residual space in x direction from a given point.
395    fn compute_residual_x(&self, x: f64, y: f64, z: f64) -> f64 {
396        let container_max_x = self.container.x - self.margin;
397        let mut min_x = container_max_x;
398
399        for placed in &self.placed {
400            let p_max = placed.max_corner();
401            // Check if this box blocks in x direction
402            if placed.position.y < y + 1e-9
403                && p_max.y > y - 1e-9
404                && placed.position.z < z + 1e-9
405                && p_max.z > z - 1e-9
406                && placed.position.x > x - 1e-9
407                && placed.position.x < min_x
408            {
409                min_x = placed.position.x;
410            }
411        }
412
413        (min_x - x).max(0.0)
414    }
415
416    /// Computes residual space in y direction from a given point.
417    fn compute_residual_y(&self, x: f64, y: f64, z: f64) -> f64 {
418        let container_max_y = self.container.y - self.margin;
419        let mut min_y = container_max_y;
420
421        for placed in &self.placed {
422            let p_max = placed.max_corner();
423            // Check if this box blocks in y direction
424            if placed.position.x < x + 1e-9
425                && p_max.x > x - 1e-9
426                && placed.position.z < z + 1e-9
427                && p_max.z > z - 1e-9
428                && placed.position.y > y - 1e-9
429                && placed.position.y < min_y
430            {
431                min_y = placed.position.y;
432            }
433        }
434
435        (min_y - y).max(0.0)
436    }
437
438    /// Computes residual space in z direction from a given point.
439    fn compute_residual_z(&self, x: f64, y: f64, z: f64) -> f64 {
440        let container_max_z = self.container.z - self.margin;
441        let mut min_z = container_max_z;
442
443        for placed in &self.placed {
444            let p_max = placed.max_corner();
445            // Check if this box blocks in z direction
446            if placed.position.x < x + 1e-9
447                && p_max.x > x - 1e-9
448                && placed.position.y < y + 1e-9
449                && p_max.y > y - 1e-9
450                && placed.position.z > z - 1e-9
451                && placed.position.z < min_z
452            {
453                min_z = placed.position.z;
454            }
455        }
456
457        (min_z - z).max(0.0)
458    }
459}
460
461/// EP selection strategy.
462#[derive(Debug, Clone, Copy, Default)]
463pub enum EpSelectionStrategy {
464    /// Select EP with lowest z, then y, then x (default).
465    #[default]
466    BottomLeftBack,
467    /// Select EP that minimizes wasted space.
468    BestFit,
469    /// Select first fitting EP.
470    FirstFit,
471}
472
473/// Result of EP packing: (geometry_id, instance, position, orientation).
474pub type EpPlacement = (String, usize, Vector3<f64>, usize);
475
476/// Runs EP-based packing.
477pub fn run_ep_packing(
478    geometries: &[Geometry3D],
479    boundary: &Boundary3D,
480    margin: f64,
481    spacing: f64,
482    max_mass: Option<f64>,
483) -> (Vec<EpPlacement>, f64) {
484    let mut eps = ExtremePointSet::new(boundary, margin, spacing);
485    let mut placements = Vec::new();
486
487    // Sort geometries by volume (largest first) for better packing
488    let mut items: Vec<(usize, usize, f64)> = Vec::new();
489    for (geom_idx, geom) in geometries.iter().enumerate() {
490        for instance in 0..geom.quantity() {
491            items.push((geom_idx, instance, geom.measure()));
492        }
493    }
494    items.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(Ordering::Equal));
495
496    for (geom_idx, instance, _) in items {
497        let geom = &geometries[geom_idx];
498
499        // Check mass constraint
500        if let (Some(max), Some(item_mass)) = (max_mass, geom.mass()) {
501            if eps.total_mass() + item_mass > max {
502                continue;
503            }
504        }
505
506        // Try each orientation
507        let mut placed = false;
508        for orientation in 0..geom.allowed_orientations().len() {
509            if let Some(position) = eps.try_place(geom, instance, orientation) {
510                placements.push((geom.id().clone(), instance, position, orientation));
511                placed = true;
512                break;
513            }
514        }
515
516        if !placed {
517            // Could not place this item
518        }
519    }
520
521    let utilization = eps.total_volume() / boundary.measure();
522    (placements, utilization)
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn test_extreme_point_creation() {
531        let ep = ExtremePoint::new(0.0, 0.0, 0.0, 100.0, 100.0, 100.0);
532        assert!(ep.fits(50.0, 50.0, 50.0));
533        assert!(!ep.fits(150.0, 50.0, 50.0));
534    }
535
536    #[test]
537    fn test_extreme_point_set_initial() {
538        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
539        let eps = ExtremePointSet::new(&boundary, 0.0, 0.0);
540
541        assert_eq!(eps.len(), 1);
542        assert!(eps.is_empty() == false);
543    }
544
545    #[test]
546    fn test_ep_packing_single_box() {
547        let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0)];
548        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
549
550        let (placements, utilization) = run_ep_packing(&geometries, &boundary, 0.0, 0.0, None);
551
552        assert_eq!(placements.len(), 1);
553        assert!(utilization > 0.0);
554    }
555
556    #[test]
557    fn test_ep_packing_multiple_boxes() {
558        let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(8)];
559        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
560
561        let (placements, utilization) = run_ep_packing(&geometries, &boundary, 0.0, 0.0, None);
562
563        // Should be able to fit multiple boxes
564        assert!(placements.len() >= 4);
565        assert!(utilization > 0.05);
566    }
567
568    #[test]
569    fn test_ep_packing_with_margin() {
570        let geometries = vec![Geometry3D::new("B1", 20.0, 20.0, 20.0).with_quantity(4)];
571        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
572
573        let (placements, _) = run_ep_packing(&geometries, &boundary, 5.0, 0.0, None);
574
575        // With margin, first box should start at (5, 5, 5)
576        if !placements.is_empty() {
577            let (_, _, pos, _) = &placements[0];
578            assert!(pos.x >= 4.9);
579            assert!(pos.y >= 4.9);
580            assert!(pos.z >= 4.9);
581        }
582    }
583
584    #[test]
585    fn test_ep_packing_with_spacing() {
586        let geometries = vec![Geometry3D::new("B1", 40.0, 40.0, 40.0).with_quantity(4)];
587        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
588
589        let (placements_no_spacing, _) = run_ep_packing(&geometries, &boundary, 0.0, 0.0, None);
590        let (placements_with_spacing, _) = run_ep_packing(&geometries, &boundary, 0.0, 5.0, None);
591
592        // With spacing, fewer boxes might fit
593        assert!(placements_with_spacing.len() <= placements_no_spacing.len());
594    }
595
596    #[test]
597    fn test_placed_box_overlap() {
598        let box1 = PlacedBox {
599            id: "A".to_string(),
600            instance: 0,
601            position: Vector3::new(0.0, 0.0, 0.0),
602            dimensions: Vector3::new(10.0, 10.0, 10.0),
603            mass: None,
604        };
605
606        let box2_overlap = PlacedBox {
607            id: "B".to_string(),
608            instance: 0,
609            position: Vector3::new(5.0, 5.0, 5.0),
610            dimensions: Vector3::new(10.0, 10.0, 10.0),
611            mass: None,
612        };
613
614        let box2_no_overlap = PlacedBox {
615            id: "C".to_string(),
616            instance: 0,
617            position: Vector3::new(15.0, 0.0, 0.0),
618            dimensions: Vector3::new(10.0, 10.0, 10.0),
619            mass: None,
620        };
621
622        assert!(box1.overlaps(&box2_overlap));
623        assert!(!box1.overlaps(&box2_no_overlap));
624    }
625
626    #[test]
627    fn test_ep_packing_orientations() {
628        use crate::geometry::OrientationConstraint;
629
630        // Long box that benefits from rotation
631        let geometries = vec![Geometry3D::new("B1", 80.0, 10.0, 10.0)
632            .with_quantity(2)
633            .with_orientation(OrientationConstraint::Any)];
634        let boundary = Boundary3D::new(100.0, 100.0, 100.0);
635
636        let (placements, _) = run_ep_packing(&geometries, &boundary, 0.0, 0.0, None);
637
638        // With orientation flexibility, both should fit
639        assert_eq!(placements.len(), 2);
640    }
641}