Skip to main content

u_nesting_d3/
stability.rs

1//! Stability analysis for 3D bin packing.
2//!
3//! This module provides various stability constraints for validating 3D placements.
4//! Stability is crucial in real-world logistics and manufacturing scenarios.
5//!
6//! # Stability Models
7//!
8//! The module implements a hierarchy of stability models from simple to complex:
9//!
10//! 1. **Full Base Support**: 100% of the bottom face must be supported
11//! 2. **Partial Base Support**: A configurable percentage (e.g., 70-80%) must be supported
12//! 3. **Center of Gravity (CoG) Polygon**: CoG projection must fall within support polygon
13//! 4. **Static Mechanical Equilibrium**: Full force/moment balance analysis
14//!
15//! # Example
16//!
17//! ```ignore
18//! use u_nesting_d3::stability::{StabilityConstraint, StabilityAnalyzer};
19//! use u_nesting_d3::{Geometry3D, Boundary3D, Packer3D, Config};
20//!
21//! let analyzer = StabilityAnalyzer::new(StabilityConstraint::PartialBase { min_ratio: 0.75 });
22//! let placements = /* ... */;
23//! let report = analyzer.analyze(&placements, &geometries);
24//! ```
25
26use std::collections::HashMap;
27use u_nesting_core::geom::nalgebra_types::{NaPoint3 as Point3, NaVector3 as Vector3};
28use u_nesting_core::timing::Timer;
29
30#[cfg(feature = "serde")]
31use serde::{Deserialize, Serialize};
32
33/// Stability constraint type for 3D packing.
34#[derive(Debug, Clone, Copy, PartialEq, Default)]
35#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
36pub enum StabilityConstraint {
37    /// No stability checking (fastest).
38    #[default]
39    None,
40
41    /// Full base support: 100% of the bottom face must be supported.
42    /// Most restrictive but guarantees stability.
43    FullBase,
44
45    /// Partial base support: at least `min_ratio` (0.0-1.0) of the bottom face
46    /// must be supported. Common values: 0.7-0.8.
47    PartialBase {
48        /// Minimum support ratio (0.0-1.0).
49        min_ratio: f64,
50    },
51
52    /// Center of Gravity polygon support: the projection of the item's
53    /// center of gravity must fall within the convex hull of support points.
54    CogPolygon,
55
56    /// Static mechanical equilibrium: full Newton's laws analysis.
57    /// ΣF = 0, ΣM = 0 for all contact forces.
58    /// Most accurate but computationally expensive.
59    StaticEquilibrium {
60        /// Tolerance for force balance (default: 1e-6).
61        force_tolerance: f64,
62        /// Tolerance for moment balance (default: 1e-6).
63        moment_tolerance: f64,
64    },
65}
66
67impl StabilityConstraint {
68    /// Creates a partial base support constraint with the given ratio.
69    pub fn partial_base(min_ratio: f64) -> Self {
70        Self::PartialBase {
71            min_ratio: min_ratio.clamp(0.0, 1.0),
72        }
73    }
74
75    /// Creates a static equilibrium constraint with default tolerances.
76    pub fn static_equilibrium() -> Self {
77        Self::StaticEquilibrium {
78            force_tolerance: 1e-6,
79            moment_tolerance: 1e-6,
80        }
81    }
82
83    /// Returns true if this constraint requires checking.
84    pub fn is_enabled(&self) -> bool {
85        !matches!(self, Self::None)
86    }
87}
88
89/// A placed box with position and dimensions.
90#[derive(Debug, Clone)]
91pub struct PlacedBox {
92    /// Unique identifier.
93    pub id: String,
94    /// Instance index.
95    pub instance: usize,
96    /// Position (bottom-left-front corner).
97    pub position: Point3<f64>,
98    /// Dimensions (width, depth, height) after orientation applied.
99    pub dimensions: Vector3<f64>,
100    /// Mass of the box (optional).
101    pub mass: Option<f64>,
102    /// Center of gravity offset from geometric center (optional).
103    pub cog_offset: Option<Vector3<f64>>,
104}
105
106impl PlacedBox {
107    /// Creates a new placed box.
108    pub fn new(
109        id: impl Into<String>,
110        instance: usize,
111        position: Point3<f64>,
112        dimensions: Vector3<f64>,
113    ) -> Self {
114        Self {
115            id: id.into(),
116            instance,
117            position,
118            dimensions,
119            mass: None,
120            cog_offset: None,
121        }
122    }
123
124    /// Sets the mass of the box.
125    pub fn with_mass(mut self, mass: f64) -> Self {
126        self.mass = Some(mass);
127        self
128    }
129
130    /// Sets the center of gravity offset from geometric center.
131    pub fn with_cog_offset(mut self, offset: Vector3<f64>) -> Self {
132        self.cog_offset = Some(offset);
133        self
134    }
135
136    /// Returns the geometric center of the box.
137    pub fn center(&self) -> Point3<f64> {
138        Point3::new(
139            self.position.x + self.dimensions.x / 2.0,
140            self.position.y + self.dimensions.y / 2.0,
141            self.position.z + self.dimensions.z / 2.0,
142        )
143    }
144
145    /// Returns the center of gravity.
146    pub fn center_of_gravity(&self) -> Point3<f64> {
147        let center = self.center();
148        if let Some(offset) = self.cog_offset {
149            Point3::new(
150                center.x + offset.x,
151                center.y + offset.y,
152                center.z + offset.z,
153            )
154        } else {
155            center
156        }
157    }
158
159    /// Returns the bottom face AABB (z = position.z).
160    pub fn bottom_face(&self) -> (Point3<f64>, Point3<f64>) {
161        (
162            Point3::new(self.position.x, self.position.y, self.position.z),
163            Point3::new(
164                self.position.x + self.dimensions.x,
165                self.position.y + self.dimensions.y,
166                self.position.z,
167            ),
168        )
169    }
170
171    /// Returns the top face AABB.
172    pub fn top_face(&self) -> (Point3<f64>, Point3<f64>) {
173        let top_z = self.position.z + self.dimensions.z;
174        (
175            Point3::new(self.position.x, self.position.y, top_z),
176            Point3::new(
177                self.position.x + self.dimensions.x,
178                self.position.y + self.dimensions.y,
179                top_z,
180            ),
181        )
182    }
183
184    /// Returns the volume of the box.
185    pub fn volume(&self) -> f64 {
186        self.dimensions.x * self.dimensions.y * self.dimensions.z
187    }
188
189    /// Returns the bottom face area.
190    pub fn base_area(&self) -> f64 {
191        self.dimensions.x * self.dimensions.y
192    }
193}
194
195/// Result of stability analysis for a single box.
196#[derive(Debug, Clone)]
197#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
198pub struct StabilityResult {
199    /// Box identifier.
200    pub id: String,
201    /// Instance index.
202    pub instance: usize,
203    /// Whether the box is stable.
204    pub is_stable: bool,
205    /// Support ratio (0.0-1.0) - ratio of bottom face that is supported.
206    pub support_ratio: f64,
207    /// Boxes providing support to this box.
208    pub supported_by: Vec<(String, usize)>,
209    /// Whether the CoG is within the support polygon.
210    pub cog_within_support: bool,
211    /// Force imbalance magnitude (for equilibrium check).
212    pub force_imbalance: f64,
213    /// Moment imbalance magnitude (for equilibrium check).
214    pub moment_imbalance: f64,
215    /// Stability score (0.0-1.0, higher is more stable).
216    pub stability_score: f64,
217}
218
219impl StabilityResult {
220    /// Creates a new stability result.
221    pub fn new(id: impl Into<String>, instance: usize) -> Self {
222        Self {
223            id: id.into(),
224            instance,
225            is_stable: true,
226            support_ratio: 1.0,
227            supported_by: Vec::new(),
228            cog_within_support: true,
229            force_imbalance: 0.0,
230            moment_imbalance: 0.0,
231            stability_score: 1.0,
232        }
233    }
234
235    /// Marks the result as unstable.
236    pub fn unstable(mut self) -> Self {
237        self.is_stable = false;
238        self.stability_score = 0.0;
239        self
240    }
241}
242
243/// Complete stability report for a packing solution.
244#[derive(Debug, Clone)]
245#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
246pub struct StabilityReport {
247    /// Individual results for each box.
248    pub results: Vec<StabilityResult>,
249    /// Number of stable boxes.
250    pub stable_count: usize,
251    /// Number of unstable boxes.
252    pub unstable_count: usize,
253    /// Overall stability ratio.
254    pub overall_stability: f64,
255    /// Minimum support ratio among all boxes.
256    pub min_support_ratio: f64,
257    /// Average support ratio.
258    pub avg_support_ratio: f64,
259    /// Total weight of unstable boxes.
260    pub unstable_weight: f64,
261    /// Analysis time in milliseconds.
262    pub analysis_time_ms: u64,
263}
264
265impl StabilityReport {
266    /// Creates a new empty report.
267    pub fn new() -> Self {
268        Self {
269            results: Vec::new(),
270            stable_count: 0,
271            unstable_count: 0,
272            overall_stability: 1.0,
273            min_support_ratio: 1.0,
274            avg_support_ratio: 1.0,
275            unstable_weight: 0.0,
276            analysis_time_ms: 0,
277        }
278    }
279
280    /// Returns true if all boxes are stable.
281    pub fn is_all_stable(&self) -> bool {
282        self.unstable_count == 0
283    }
284
285    /// Returns the unstable boxes.
286    pub fn unstable_boxes(&self) -> Vec<&StabilityResult> {
287        self.results.iter().filter(|r| !r.is_stable).collect()
288    }
289}
290
291impl Default for StabilityReport {
292    fn default() -> Self {
293        Self::new()
294    }
295}
296
297/// Stability analyzer for 3D bin packing.
298pub struct StabilityAnalyzer {
299    constraint: StabilityConstraint,
300    /// Tolerance for considering boxes as "touching" (contact detection).
301    contact_tolerance: f64,
302    /// Gravity direction (default: -Z).
303    gravity: Vector3<f64>,
304}
305
306impl StabilityAnalyzer {
307    /// Creates a new stability analyzer with the given constraint.
308    pub fn new(constraint: StabilityConstraint) -> Self {
309        Self {
310            constraint,
311            contact_tolerance: 1e-6,
312            gravity: Vector3::new(0.0, 0.0, -9.81),
313        }
314    }
315
316    /// Sets the contact detection tolerance.
317    pub fn with_contact_tolerance(mut self, tolerance: f64) -> Self {
318        self.contact_tolerance = tolerance;
319        self
320    }
321
322    /// Sets the gravity direction.
323    pub fn with_gravity(mut self, gravity: Vector3<f64>) -> Self {
324        self.gravity = gravity;
325        self
326    }
327
328    /// Analyzes the stability of all placed boxes.
329    pub fn analyze(&self, boxes: &[PlacedBox], floor_z: f64) -> StabilityReport {
330        let start = Timer::now();
331        let mut report = StabilityReport::new();
332
333        if boxes.is_empty() || !self.constraint.is_enabled() {
334            report.analysis_time_ms = start.elapsed_ms();
335            return report;
336        }
337
338        // Build spatial lookup for efficient contact detection
339        let boxes_by_top_z = self.build_top_z_index(boxes);
340
341        // Analyze each box
342        for placed_box in boxes {
343            let result = self.analyze_box(placed_box, boxes, floor_z, &boxes_by_top_z);
344            report.results.push(result);
345        }
346
347        // Compute summary statistics
348        self.compute_summary(&mut report, boxes);
349        report.analysis_time_ms = start.elapsed_ms();
350
351        report
352    }
353
354    /// Checks if a single placement is stable.
355    pub fn is_stable(&self, placed_box: &PlacedBox, supports: &[PlacedBox], floor_z: f64) -> bool {
356        let all_boxes: Vec<_> = std::iter::once(placed_box.clone())
357            .chain(supports.iter().cloned())
358            .collect();
359
360        let boxes_by_top_z = self.build_top_z_index(&all_boxes);
361        let result = self.analyze_box(placed_box, &all_boxes, floor_z, &boxes_by_top_z);
362        result.is_stable
363    }
364
365    /// Analyzes stability of a single box.
366    fn analyze_box(
367        &self,
368        placed_box: &PlacedBox,
369        all_boxes: &[PlacedBox],
370        floor_z: f64,
371        boxes_by_top_z: &HashMap<i64, Vec<usize>>,
372    ) -> StabilityResult {
373        let mut result = StabilityResult::new(&placed_box.id, placed_box.instance);
374
375        // Find supporting surfaces
376        let (support_ratio, supporters) =
377            self.compute_support(placed_box, all_boxes, floor_z, boxes_by_top_z);
378
379        result.support_ratio = support_ratio;
380        result.supported_by = supporters;
381
382        // Apply constraint check
383        result.is_stable = match &self.constraint {
384            StabilityConstraint::None => true,
385            StabilityConstraint::FullBase => support_ratio >= 1.0 - self.contact_tolerance,
386            StabilityConstraint::PartialBase { min_ratio } => support_ratio >= *min_ratio,
387            StabilityConstraint::CogPolygon => {
388                let cog_ok = self.check_cog_support(placed_box, all_boxes, floor_z, boxes_by_top_z);
389                result.cog_within_support = cog_ok;
390                cog_ok
391            }
392            StabilityConstraint::StaticEquilibrium {
393                force_tolerance,
394                moment_tolerance,
395            } => {
396                let (force_imb, moment_imb) =
397                    self.check_equilibrium(placed_box, all_boxes, floor_z, boxes_by_top_z);
398                result.force_imbalance = force_imb;
399                result.moment_imbalance = moment_imb;
400                force_imb <= *force_tolerance && moment_imb <= *moment_tolerance
401            }
402        };
403
404        // Compute stability score
405        result.stability_score = self.compute_stability_score(&result);
406
407        result
408    }
409
410    /// Computes the support ratio and list of supporting boxes.
411    fn compute_support(
412        &self,
413        placed_box: &PlacedBox,
414        all_boxes: &[PlacedBox],
415        floor_z: f64,
416        boxes_by_top_z: &HashMap<i64, Vec<usize>>,
417    ) -> (f64, Vec<(String, usize)>) {
418        let bottom_z = placed_box.position.z;
419        let base_area = placed_box.base_area();
420        let (bottom_min, bottom_max) = placed_box.bottom_face();
421
422        // Check if on floor
423        if (bottom_z - floor_z).abs() <= self.contact_tolerance {
424            return (1.0, vec![("floor".to_string(), 0)]);
425        }
426
427        // Find boxes whose top face is at this box's bottom
428        let target_z_key = (bottom_z * 1000.0).round() as i64;
429        let mut total_support_area = 0.0;
430        let mut supporters = Vec::new();
431
432        // Check boxes at the same z level (with tolerance)
433        for dz in -1i64..=1 {
434            if let Some(box_indices) = boxes_by_top_z.get(&(target_z_key + dz)) {
435                for &idx in box_indices {
436                    let support_box = &all_boxes[idx];
437
438                    // Skip self
439                    if support_box.id == placed_box.id
440                        && support_box.instance == placed_box.instance
441                    {
442                        continue;
443                    }
444
445                    let (top_min, top_max) = support_box.top_face();
446
447                    // Check if top face is at bottom z (within tolerance)
448                    if (top_min.z - bottom_z).abs() > self.contact_tolerance {
449                        continue;
450                    }
451
452                    // Compute overlap area
453                    let overlap = self.compute_face_overlap(
454                        (bottom_min.x, bottom_min.y, bottom_max.x, bottom_max.y),
455                        (top_min.x, top_min.y, top_max.x, top_max.y),
456                    );
457
458                    if overlap > 0.0 {
459                        total_support_area += overlap;
460                        supporters.push((support_box.id.clone(), support_box.instance));
461                    }
462                }
463            }
464        }
465
466        let support_ratio = (total_support_area / base_area).min(1.0);
467        (support_ratio, supporters)
468    }
469
470    /// Checks if the CoG projection falls within the support polygon.
471    fn check_cog_support(
472        &self,
473        placed_box: &PlacedBox,
474        all_boxes: &[PlacedBox],
475        floor_z: f64,
476        boxes_by_top_z: &HashMap<i64, Vec<usize>>,
477    ) -> bool {
478        let cog = placed_box.center_of_gravity();
479        let bottom_z = placed_box.position.z;
480
481        // CoG projection point (x, y)
482        let cog_xy = (cog.x, cog.y);
483
484        // Check if on floor - CoG must be within base
485        if (bottom_z - floor_z).abs() <= self.contact_tolerance {
486            let (min, max) = placed_box.bottom_face();
487            return cog_xy.0 >= min.x
488                && cog_xy.0 <= max.x
489                && cog_xy.1 >= min.y
490                && cog_xy.1 <= max.y;
491        }
492
493        // Collect support regions
494        let mut support_regions: Vec<(f64, f64, f64, f64)> = Vec::new();
495        let target_z_key = (bottom_z * 1000.0).round() as i64;
496
497        for dz in -1i64..=1 {
498            if let Some(box_indices) = boxes_by_top_z.get(&(target_z_key + dz)) {
499                for &idx in box_indices {
500                    let support_box = &all_boxes[idx];
501                    if support_box.id == placed_box.id
502                        && support_box.instance == placed_box.instance
503                    {
504                        continue;
505                    }
506
507                    let (top_min, top_max) = support_box.top_face();
508                    if (top_min.z - bottom_z).abs() <= self.contact_tolerance {
509                        let (bottom_min, bottom_max) = placed_box.bottom_face();
510                        let overlap = self.compute_face_overlap_coords(
511                            (bottom_min.x, bottom_min.y, bottom_max.x, bottom_max.y),
512                            (top_min.x, top_min.y, top_max.x, top_max.y),
513                        );
514                        if let Some(region) = overlap {
515                            support_regions.push(region);
516                        }
517                    }
518                }
519            }
520        }
521
522        // Check if CoG projection is within any support region
523        for (min_x, min_y, max_x, max_y) in support_regions {
524            if cog_xy.0 >= min_x && cog_xy.0 <= max_x && cog_xy.1 >= min_y && cog_xy.1 <= max_y {
525                return true;
526            }
527        }
528
529        // For a more accurate check, compute convex hull of support regions
530        // and check if CoG is inside. Simplified: check if CoG is close to
531        // any support region center.
532        false
533    }
534
535    /// Checks static mechanical equilibrium (force and moment balance).
536    fn check_equilibrium(
537        &self,
538        placed_box: &PlacedBox,
539        all_boxes: &[PlacedBox],
540        floor_z: f64,
541        boxes_by_top_z: &HashMap<i64, Vec<usize>>,
542    ) -> (f64, f64) {
543        // This is a simplified equilibrium check.
544        // Full implementation would solve contact force distribution.
545
546        let mass = placed_box.mass.unwrap_or(1.0);
547        // CoG would be used in a full moment calculation
548        let _cog = placed_box.center_of_gravity();
549
550        // Gravity force
551        let gravity_force = Vector3::new(0.0, 0.0, -mass * 9.81);
552
553        // Compute support forces (simplified: assume uniform distribution)
554        let (support_ratio, _supporters) =
555            self.compute_support(placed_box, all_boxes, floor_z, boxes_by_top_z);
556
557        if support_ratio < self.contact_tolerance {
558            // No support - maximum imbalance
559            return (gravity_force.norm(), f64::MAX);
560        }
561
562        // Simplified: assume reaction force equals gravity if fully supported
563        let reaction_force = -gravity_force * support_ratio;
564        let net_force = gravity_force + reaction_force;
565        let force_imbalance = net_force.norm();
566
567        // Moment check (simplified)
568        // In a full implementation, compute moments about support polygon centroid
569        let moment_imbalance = if support_ratio >= 0.5 {
570            0.0 // Assume balanced if well supported
571        } else {
572            // Approximate: higher imbalance with less support
573            mass * 9.81 * (1.0 - support_ratio) * placed_box.dimensions.z / 2.0
574        };
575
576        (force_imbalance, moment_imbalance)
577    }
578
579    /// Computes overlap area between two axis-aligned rectangles.
580    fn compute_face_overlap(
581        &self,
582        face1: (f64, f64, f64, f64),
583        face2: (f64, f64, f64, f64),
584    ) -> f64 {
585        let (x1_min, y1_min, x1_max, y1_max) = face1;
586        let (x2_min, y2_min, x2_max, y2_max) = face2;
587
588        let x_overlap = (x1_max.min(x2_max) - x1_min.max(x2_min)).max(0.0);
589        let y_overlap = (y1_max.min(y2_max) - y1_min.max(y2_min)).max(0.0);
590
591        x_overlap * y_overlap
592    }
593
594    /// Computes overlap region coordinates between two rectangles.
595    fn compute_face_overlap_coords(
596        &self,
597        face1: (f64, f64, f64, f64),
598        face2: (f64, f64, f64, f64),
599    ) -> Option<(f64, f64, f64, f64)> {
600        let (x1_min, y1_min, x1_max, y1_max) = face1;
601        let (x2_min, y2_min, x2_max, y2_max) = face2;
602
603        let x_min = x1_min.max(x2_min);
604        let y_min = y1_min.max(y2_min);
605        let x_max = x1_max.min(x2_max);
606        let y_max = y1_max.min(y2_max);
607
608        if x_max > x_min && y_max > y_min {
609            Some((x_min, y_min, x_max, y_max))
610        } else {
611            None
612        }
613    }
614
615    /// Builds an index of boxes by their top Z coordinate.
616    fn build_top_z_index(&self, boxes: &[PlacedBox]) -> HashMap<i64, Vec<usize>> {
617        let mut index: HashMap<i64, Vec<usize>> = HashMap::new();
618        for (i, b) in boxes.iter().enumerate() {
619            let top_z = b.position.z + b.dimensions.z;
620            let key = (top_z * 1000.0).round() as i64;
621            index.entry(key).or_default().push(i);
622        }
623        index
624    }
625
626    /// Computes a stability score (0.0-1.0) from the result.
627    fn compute_stability_score(&self, result: &StabilityResult) -> f64 {
628        if !result.is_stable {
629            return 0.0;
630        }
631
632        // Weighted combination of factors
633        let support_score = result.support_ratio;
634        let cog_score = if result.cog_within_support { 1.0 } else { 0.5 };
635
636        // Combine scores
637        (0.7 * support_score + 0.3 * cog_score).clamp(0.0, 1.0)
638    }
639
640    /// Computes summary statistics for the report.
641    fn compute_summary(&self, report: &mut StabilityReport, boxes: &[PlacedBox]) {
642        let total = report.results.len();
643        if total == 0 {
644            return;
645        }
646
647        report.stable_count = report.results.iter().filter(|r| r.is_stable).count();
648        report.unstable_count = total - report.stable_count;
649        report.overall_stability = report.stable_count as f64 / total as f64;
650
651        report.min_support_ratio = report
652            .results
653            .iter()
654            .map(|r| r.support_ratio)
655            .fold(f64::MAX, f64::min);
656
657        report.avg_support_ratio =
658            report.results.iter().map(|r| r.support_ratio).sum::<f64>() / total as f64;
659
660        // Compute unstable weight
661        for result in &report.results {
662            if !result.is_stable {
663                // Find corresponding box and add its mass
664                if let Some(b) = boxes
665                    .iter()
666                    .find(|b| b.id == result.id && b.instance == result.instance)
667                {
668                    report.unstable_weight += b.mass.unwrap_or(0.0);
669                }
670            }
671        }
672    }
673}
674
675impl Default for StabilityAnalyzer {
676    fn default() -> Self {
677        Self::new(StabilityConstraint::default())
678    }
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    #[test]
686    fn test_stability_constraint_default() {
687        let constraint = StabilityConstraint::default();
688        assert!(!constraint.is_enabled());
689    }
690
691    #[test]
692    fn test_stability_constraint_partial_base() {
693        let constraint = StabilityConstraint::partial_base(0.75);
694        assert!(constraint.is_enabled());
695        if let StabilityConstraint::PartialBase { min_ratio } = constraint {
696            assert!((min_ratio - 0.75).abs() < 0.001);
697        } else {
698            panic!("Expected PartialBase");
699        }
700    }
701
702    #[test]
703    fn test_placed_box_center() {
704        let b = PlacedBox::new(
705            "B1",
706            0,
707            Point3::new(10.0, 20.0, 30.0),
708            Vector3::new(100.0, 50.0, 40.0),
709        );
710
711        let center = b.center();
712        assert!((center.x - 60.0).abs() < 0.001);
713        assert!((center.y - 45.0).abs() < 0.001);
714        assert!((center.z - 50.0).abs() < 0.001);
715    }
716
717    #[test]
718    fn test_placed_box_with_cog_offset() {
719        let b = PlacedBox::new(
720            "B1",
721            0,
722            Point3::new(0.0, 0.0, 0.0),
723            Vector3::new(100.0, 100.0, 100.0),
724        )
725        .with_cog_offset(Vector3::new(10.0, 0.0, -5.0));
726
727        let cog = b.center_of_gravity();
728        assert!((cog.x - 60.0).abs() < 0.001); // 50 + 10
729        assert!((cog.y - 50.0).abs() < 0.001);
730        assert!((cog.z - 45.0).abs() < 0.001); // 50 - 5
731    }
732
733    #[test]
734    fn test_box_on_floor_is_stable() {
735        let analyzer = StabilityAnalyzer::new(StabilityConstraint::FullBase);
736
737        let boxes = vec![PlacedBox::new(
738            "B1",
739            0,
740            Point3::new(0.0, 0.0, 0.0),
741            Vector3::new(100.0, 100.0, 50.0),
742        )];
743
744        let report = analyzer.analyze(&boxes, 0.0);
745
746        assert!(report.is_all_stable());
747        assert_eq!(report.stable_count, 1);
748        assert!((report.results[0].support_ratio - 1.0).abs() < 0.001);
749    }
750
751    #[test]
752    fn test_stacked_boxes_full_support() {
753        let analyzer = StabilityAnalyzer::new(StabilityConstraint::FullBase);
754
755        let boxes = vec![
756            PlacedBox::new(
757                "B1",
758                0,
759                Point3::new(0.0, 0.0, 0.0),
760                Vector3::new(100.0, 100.0, 50.0),
761            ),
762            PlacedBox::new(
763                "B2",
764                0,
765                Point3::new(0.0, 0.0, 50.0),
766                Vector3::new(100.0, 100.0, 50.0),
767            ),
768        ];
769
770        let report = analyzer.analyze(&boxes, 0.0);
771
772        assert!(report.is_all_stable());
773        assert_eq!(report.stable_count, 2);
774    }
775
776    #[test]
777    fn test_stacked_box_partial_support() {
778        let analyzer = StabilityAnalyzer::new(StabilityConstraint::partial_base(0.5));
779
780        let boxes = vec![
781            // Base box
782            PlacedBox::new(
783                "B1",
784                0,
785                Point3::new(0.0, 0.0, 0.0),
786                Vector3::new(100.0, 100.0, 50.0),
787            ),
788            // Top box shifted, only 50% supported
789            PlacedBox::new(
790                "B2",
791                0,
792                Point3::new(50.0, 0.0, 50.0),
793                Vector3::new(100.0, 100.0, 50.0),
794            ),
795        ];
796
797        let report = analyzer.analyze(&boxes, 0.0);
798
799        // Both should be stable (50% support meets 50% requirement)
800        assert!(report.results[0].is_stable); // B1 on floor
801        assert!(report.results[1].is_stable); // B2 has 50% support >= 50% required
802    }
803
804    #[test]
805    fn test_unsupported_box_is_unstable() {
806        let analyzer = StabilityAnalyzer::new(StabilityConstraint::FullBase);
807
808        let boxes = vec![
809            // Base box
810            PlacedBox::new(
811                "B1",
812                0,
813                Point3::new(0.0, 0.0, 0.0),
814                Vector3::new(50.0, 50.0, 50.0),
815            ),
816            // Floating box - no support
817            PlacedBox::new(
818                "B2",
819                0,
820                Point3::new(100.0, 100.0, 50.0),
821                Vector3::new(50.0, 50.0, 50.0),
822            ),
823        ];
824
825        let report = analyzer.analyze(&boxes, 0.0);
826
827        assert!(!report.is_all_stable());
828        assert_eq!(report.unstable_count, 1);
829        assert!(!report.results[1].is_stable);
830    }
831
832    #[test]
833    fn test_cog_stability_check() {
834        let analyzer = StabilityAnalyzer::new(StabilityConstraint::CogPolygon);
835
836        let boxes = vec![PlacedBox::new(
837            "B1",
838            0,
839            Point3::new(0.0, 0.0, 0.0),
840            Vector3::new(100.0, 100.0, 50.0),
841        )];
842
843        let report = analyzer.analyze(&boxes, 0.0);
844
845        assert!(report.results[0].cog_within_support);
846        assert!(report.results[0].is_stable);
847    }
848
849    #[test]
850    fn test_equilibrium_check() {
851        let analyzer = StabilityAnalyzer::new(StabilityConstraint::static_equilibrium());
852
853        let boxes = vec![PlacedBox::new(
854            "B1",
855            0,
856            Point3::new(0.0, 0.0, 0.0),
857            Vector3::new(100.0, 100.0, 50.0),
858        )
859        .with_mass(10.0)];
860
861        let report = analyzer.analyze(&boxes, 0.0);
862
863        assert!(report.results[0].is_stable);
864        assert!(report.results[0].force_imbalance < 1.0);
865    }
866
867    #[test]
868    fn test_stability_report_summary() {
869        let analyzer = StabilityAnalyzer::new(StabilityConstraint::partial_base(0.8));
870
871        let boxes = vec![
872            PlacedBox::new(
873                "B1",
874                0,
875                Point3::new(0.0, 0.0, 0.0),
876                Vector3::new(100.0, 100.0, 50.0),
877            )
878            .with_mass(5.0),
879            PlacedBox::new(
880                "B2",
881                0,
882                Point3::new(50.0, 50.0, 50.0),
883                Vector3::new(100.0, 100.0, 50.0),
884            )
885            .with_mass(3.0),
886        ];
887
888        let report = analyzer.analyze(&boxes, 0.0);
889
890        assert_eq!(report.results.len(), 2);
891        assert!(report.analysis_time_ms < 1000); // Should be fast
892    }
893
894    #[test]
895    fn test_no_constraint_always_stable() {
896        let analyzer = StabilityAnalyzer::new(StabilityConstraint::None);
897
898        let boxes = vec![
899            // Floating box - normally unstable
900            PlacedBox::new(
901                "B1",
902                0,
903                Point3::new(0.0, 0.0, 100.0), // Floating at z=100
904                Vector3::new(50.0, 50.0, 50.0),
905            ),
906        ];
907
908        let report = analyzer.analyze(&boxes, 0.0);
909
910        // With None constraint, should return empty report
911        assert!(report.results.is_empty() || report.is_all_stable());
912    }
913
914    #[test]
915    fn test_face_overlap_computation() {
916        let analyzer = StabilityAnalyzer::default();
917
918        // Full overlap
919        let area1 = analyzer.compute_face_overlap((0.0, 0.0, 10.0, 10.0), (0.0, 0.0, 10.0, 10.0));
920        assert!((area1 - 100.0).abs() < 0.001);
921
922        // Partial overlap
923        let area2 = analyzer.compute_face_overlap((0.0, 0.0, 10.0, 10.0), (5.0, 5.0, 15.0, 15.0));
924        assert!((area2 - 25.0).abs() < 0.001); // 5x5 overlap
925
926        // No overlap
927        let area3 = analyzer.compute_face_overlap((0.0, 0.0, 10.0, 10.0), (20.0, 20.0, 30.0, 30.0));
928        assert!(area3 < 0.001);
929    }
930}