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