use std::collections::HashMap;
use u_nesting_core::geom::nalgebra_types::{NaPoint3 as Point3, NaVector3 as Vector3};
use u_nesting_core::timing::Timer;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum StabilityConstraint {
#[default]
None,
FullBase,
PartialBase {
min_ratio: f64,
},
CogPolygon,
StaticEquilibrium {
force_tolerance: f64,
moment_tolerance: f64,
},
}
impl StabilityConstraint {
pub fn partial_base(min_ratio: f64) -> Self {
Self::PartialBase {
min_ratio: min_ratio.clamp(0.0, 1.0),
}
}
pub fn static_equilibrium() -> Self {
Self::StaticEquilibrium {
force_tolerance: 1e-6,
moment_tolerance: 1e-6,
}
}
pub fn is_enabled(&self) -> bool {
!matches!(self, Self::None)
}
}
#[derive(Debug, Clone)]
pub struct PlacedBox {
pub id: String,
pub instance: usize,
pub position: Point3<f64>,
pub dimensions: Vector3<f64>,
pub mass: Option<f64>,
pub cog_offset: Option<Vector3<f64>>,
}
impl PlacedBox {
pub fn new(
id: impl Into<String>,
instance: usize,
position: Point3<f64>,
dimensions: Vector3<f64>,
) -> Self {
Self {
id: id.into(),
instance,
position,
dimensions,
mass: None,
cog_offset: None,
}
}
pub fn with_mass(mut self, mass: f64) -> Self {
self.mass = Some(mass);
self
}
pub fn with_cog_offset(mut self, offset: Vector3<f64>) -> Self {
self.cog_offset = Some(offset);
self
}
pub fn center(&self) -> Point3<f64> {
Point3::new(
self.position.x + self.dimensions.x / 2.0,
self.position.y + self.dimensions.y / 2.0,
self.position.z + self.dimensions.z / 2.0,
)
}
pub fn center_of_gravity(&self) -> Point3<f64> {
let center = self.center();
if let Some(offset) = self.cog_offset {
Point3::new(
center.x + offset.x,
center.y + offset.y,
center.z + offset.z,
)
} else {
center
}
}
pub fn bottom_face(&self) -> (Point3<f64>, Point3<f64>) {
(
Point3::new(self.position.x, self.position.y, self.position.z),
Point3::new(
self.position.x + self.dimensions.x,
self.position.y + self.dimensions.y,
self.position.z,
),
)
}
pub fn top_face(&self) -> (Point3<f64>, Point3<f64>) {
let top_z = self.position.z + self.dimensions.z;
(
Point3::new(self.position.x, self.position.y, top_z),
Point3::new(
self.position.x + self.dimensions.x,
self.position.y + self.dimensions.y,
top_z,
),
)
}
pub fn volume(&self) -> f64 {
self.dimensions.x * self.dimensions.y * self.dimensions.z
}
pub fn base_area(&self) -> f64 {
self.dimensions.x * self.dimensions.y
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct StabilityResult {
pub id: String,
pub instance: usize,
pub is_stable: bool,
pub support_ratio: f64,
pub supported_by: Vec<(String, usize)>,
pub cog_within_support: bool,
pub force_imbalance: f64,
pub moment_imbalance: f64,
pub stability_score: f64,
}
impl StabilityResult {
pub fn new(id: impl Into<String>, instance: usize) -> Self {
Self {
id: id.into(),
instance,
is_stable: true,
support_ratio: 1.0,
supported_by: Vec::new(),
cog_within_support: true,
force_imbalance: 0.0,
moment_imbalance: 0.0,
stability_score: 1.0,
}
}
pub fn unstable(mut self) -> Self {
self.is_stable = false;
self.stability_score = 0.0;
self
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct StabilityReport {
pub results: Vec<StabilityResult>,
pub stable_count: usize,
pub unstable_count: usize,
pub overall_stability: f64,
pub min_support_ratio: f64,
pub avg_support_ratio: f64,
pub unstable_weight: f64,
pub analysis_time_ms: u64,
}
impl StabilityReport {
pub fn new() -> Self {
Self {
results: Vec::new(),
stable_count: 0,
unstable_count: 0,
overall_stability: 1.0,
min_support_ratio: 1.0,
avg_support_ratio: 1.0,
unstable_weight: 0.0,
analysis_time_ms: 0,
}
}
pub fn is_all_stable(&self) -> bool {
self.unstable_count == 0
}
pub fn unstable_boxes(&self) -> Vec<&StabilityResult> {
self.results.iter().filter(|r| !r.is_stable).collect()
}
}
impl Default for StabilityReport {
fn default() -> Self {
Self::new()
}
}
pub struct StabilityAnalyzer {
constraint: StabilityConstraint,
contact_tolerance: f64,
gravity: Vector3<f64>,
}
impl StabilityAnalyzer {
pub fn new(constraint: StabilityConstraint) -> Self {
Self {
constraint,
contact_tolerance: 1e-6,
gravity: Vector3::new(0.0, 0.0, -9.81),
}
}
pub fn with_contact_tolerance(mut self, tolerance: f64) -> Self {
self.contact_tolerance = tolerance;
self
}
pub fn with_gravity(mut self, gravity: Vector3<f64>) -> Self {
self.gravity = gravity;
self
}
pub fn analyze(&self, boxes: &[PlacedBox], floor_z: f64) -> StabilityReport {
let start = Timer::now();
let mut report = StabilityReport::new();
if boxes.is_empty() || !self.constraint.is_enabled() {
report.analysis_time_ms = start.elapsed_ms();
return report;
}
let boxes_by_top_z = self.build_top_z_index(boxes);
for placed_box in boxes {
let result = self.analyze_box(placed_box, boxes, floor_z, &boxes_by_top_z);
report.results.push(result);
}
self.compute_summary(&mut report, boxes);
report.analysis_time_ms = start.elapsed_ms();
report
}
pub fn is_stable(&self, placed_box: &PlacedBox, supports: &[PlacedBox], floor_z: f64) -> bool {
let all_boxes: Vec<_> = std::iter::once(placed_box.clone())
.chain(supports.iter().cloned())
.collect();
let boxes_by_top_z = self.build_top_z_index(&all_boxes);
let result = self.analyze_box(placed_box, &all_boxes, floor_z, &boxes_by_top_z);
result.is_stable
}
fn analyze_box(
&self,
placed_box: &PlacedBox,
all_boxes: &[PlacedBox],
floor_z: f64,
boxes_by_top_z: &HashMap<i64, Vec<usize>>,
) -> StabilityResult {
let mut result = StabilityResult::new(&placed_box.id, placed_box.instance);
let (support_ratio, supporters) =
self.compute_support(placed_box, all_boxes, floor_z, boxes_by_top_z);
result.support_ratio = support_ratio;
result.supported_by = supporters;
result.is_stable = match &self.constraint {
StabilityConstraint::None => true,
StabilityConstraint::FullBase => support_ratio >= 1.0 - self.contact_tolerance,
StabilityConstraint::PartialBase { min_ratio } => support_ratio >= *min_ratio,
StabilityConstraint::CogPolygon => {
let cog_ok = self.check_cog_support(placed_box, all_boxes, floor_z, boxes_by_top_z);
result.cog_within_support = cog_ok;
cog_ok
}
StabilityConstraint::StaticEquilibrium {
force_tolerance,
moment_tolerance,
} => {
let (force_imb, moment_imb) =
self.check_equilibrium(placed_box, all_boxes, floor_z, boxes_by_top_z);
result.force_imbalance = force_imb;
result.moment_imbalance = moment_imb;
force_imb <= *force_tolerance && moment_imb <= *moment_tolerance
}
};
result.stability_score = self.compute_stability_score(&result);
result
}
fn compute_support(
&self,
placed_box: &PlacedBox,
all_boxes: &[PlacedBox],
floor_z: f64,
boxes_by_top_z: &HashMap<i64, Vec<usize>>,
) -> (f64, Vec<(String, usize)>) {
let bottom_z = placed_box.position.z;
let base_area = placed_box.base_area();
let (bottom_min, bottom_max) = placed_box.bottom_face();
if (bottom_z - floor_z).abs() <= self.contact_tolerance {
return (1.0, vec![("floor".to_string(), 0)]);
}
let target_z_key = (bottom_z * 1000.0).round() as i64;
let mut total_support_area = 0.0;
let mut supporters = Vec::new();
for dz in -1i64..=1 {
if let Some(box_indices) = boxes_by_top_z.get(&(target_z_key + dz)) {
for &idx in box_indices {
let support_box = &all_boxes[idx];
if support_box.id == placed_box.id
&& support_box.instance == placed_box.instance
{
continue;
}
let (top_min, top_max) = support_box.top_face();
if (top_min.z - bottom_z).abs() > self.contact_tolerance {
continue;
}
let overlap = self.compute_face_overlap(
(bottom_min.x, bottom_min.y, bottom_max.x, bottom_max.y),
(top_min.x, top_min.y, top_max.x, top_max.y),
);
if overlap > 0.0 {
total_support_area += overlap;
supporters.push((support_box.id.clone(), support_box.instance));
}
}
}
}
let support_ratio = (total_support_area / base_area).min(1.0);
(support_ratio, supporters)
}
fn check_cog_support(
&self,
placed_box: &PlacedBox,
all_boxes: &[PlacedBox],
floor_z: f64,
boxes_by_top_z: &HashMap<i64, Vec<usize>>,
) -> bool {
let cog = placed_box.center_of_gravity();
let bottom_z = placed_box.position.z;
let cog_xy = (cog.x, cog.y);
if (bottom_z - floor_z).abs() <= self.contact_tolerance {
let (min, max) = placed_box.bottom_face();
return cog_xy.0 >= min.x
&& cog_xy.0 <= max.x
&& cog_xy.1 >= min.y
&& cog_xy.1 <= max.y;
}
let mut support_regions: Vec<(f64, f64, f64, f64)> = Vec::new();
let target_z_key = (bottom_z * 1000.0).round() as i64;
for dz in -1i64..=1 {
if let Some(box_indices) = boxes_by_top_z.get(&(target_z_key + dz)) {
for &idx in box_indices {
let support_box = &all_boxes[idx];
if support_box.id == placed_box.id
&& support_box.instance == placed_box.instance
{
continue;
}
let (top_min, top_max) = support_box.top_face();
if (top_min.z - bottom_z).abs() <= self.contact_tolerance {
let (bottom_min, bottom_max) = placed_box.bottom_face();
let overlap = self.compute_face_overlap_coords(
(bottom_min.x, bottom_min.y, bottom_max.x, bottom_max.y),
(top_min.x, top_min.y, top_max.x, top_max.y),
);
if let Some(region) = overlap {
support_regions.push(region);
}
}
}
}
}
for (min_x, min_y, max_x, max_y) in support_regions {
if cog_xy.0 >= min_x && cog_xy.0 <= max_x && cog_xy.1 >= min_y && cog_xy.1 <= max_y {
return true;
}
}
false
}
fn check_equilibrium(
&self,
placed_box: &PlacedBox,
all_boxes: &[PlacedBox],
floor_z: f64,
boxes_by_top_z: &HashMap<i64, Vec<usize>>,
) -> (f64, f64) {
let mass = placed_box.mass.unwrap_or(1.0);
let _cog = placed_box.center_of_gravity();
let gravity_force = Vector3::new(0.0, 0.0, -mass * 9.81);
let (support_ratio, _supporters) =
self.compute_support(placed_box, all_boxes, floor_z, boxes_by_top_z);
if support_ratio < self.contact_tolerance {
return (gravity_force.norm(), f64::MAX);
}
let reaction_force = -gravity_force * support_ratio;
let net_force = gravity_force + reaction_force;
let force_imbalance = net_force.norm();
let moment_imbalance = if support_ratio >= 0.5 {
0.0 } else {
mass * 9.81 * (1.0 - support_ratio) * placed_box.dimensions.z / 2.0
};
(force_imbalance, moment_imbalance)
}
fn compute_face_overlap(
&self,
face1: (f64, f64, f64, f64),
face2: (f64, f64, f64, f64),
) -> f64 {
let (x1_min, y1_min, x1_max, y1_max) = face1;
let (x2_min, y2_min, x2_max, y2_max) = face2;
let x_overlap = (x1_max.min(x2_max) - x1_min.max(x2_min)).max(0.0);
let y_overlap = (y1_max.min(y2_max) - y1_min.max(y2_min)).max(0.0);
x_overlap * y_overlap
}
fn compute_face_overlap_coords(
&self,
face1: (f64, f64, f64, f64),
face2: (f64, f64, f64, f64),
) -> Option<(f64, f64, f64, f64)> {
let (x1_min, y1_min, x1_max, y1_max) = face1;
let (x2_min, y2_min, x2_max, y2_max) = face2;
let x_min = x1_min.max(x2_min);
let y_min = y1_min.max(y2_min);
let x_max = x1_max.min(x2_max);
let y_max = y1_max.min(y2_max);
if x_max > x_min && y_max > y_min {
Some((x_min, y_min, x_max, y_max))
} else {
None
}
}
fn build_top_z_index(&self, boxes: &[PlacedBox]) -> HashMap<i64, Vec<usize>> {
let mut index: HashMap<i64, Vec<usize>> = HashMap::new();
for (i, b) in boxes.iter().enumerate() {
let top_z = b.position.z + b.dimensions.z;
let key = (top_z * 1000.0).round() as i64;
index.entry(key).or_default().push(i);
}
index
}
fn compute_stability_score(&self, result: &StabilityResult) -> f64 {
if !result.is_stable {
return 0.0;
}
let support_score = result.support_ratio;
let cog_score = if result.cog_within_support { 1.0 } else { 0.5 };
(0.7 * support_score + 0.3 * cog_score).clamp(0.0, 1.0)
}
fn compute_summary(&self, report: &mut StabilityReport, boxes: &[PlacedBox]) {
let total = report.results.len();
if total == 0 {
return;
}
report.stable_count = report.results.iter().filter(|r| r.is_stable).count();
report.unstable_count = total - report.stable_count;
report.overall_stability = report.stable_count as f64 / total as f64;
report.min_support_ratio = report
.results
.iter()
.map(|r| r.support_ratio)
.fold(f64::MAX, f64::min);
report.avg_support_ratio =
report.results.iter().map(|r| r.support_ratio).sum::<f64>() / total as f64;
for result in &report.results {
if !result.is_stable {
if let Some(b) = boxes
.iter()
.find(|b| b.id == result.id && b.instance == result.instance)
{
report.unstable_weight += b.mass.unwrap_or(0.0);
}
}
}
}
}
impl Default for StabilityAnalyzer {
fn default() -> Self {
Self::new(StabilityConstraint::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_stability_constraint_default() {
let constraint = StabilityConstraint::default();
assert!(!constraint.is_enabled());
}
#[test]
fn test_stability_constraint_partial_base() {
let constraint = StabilityConstraint::partial_base(0.75);
assert!(constraint.is_enabled());
if let StabilityConstraint::PartialBase { min_ratio } = constraint {
assert!((min_ratio - 0.75).abs() < 0.001);
} else {
panic!("Expected PartialBase");
}
}
#[test]
fn test_placed_box_center() {
let b = PlacedBox::new(
"B1",
0,
Point3::new(10.0, 20.0, 30.0),
Vector3::new(100.0, 50.0, 40.0),
);
let center = b.center();
assert!((center.x - 60.0).abs() < 0.001);
assert!((center.y - 45.0).abs() < 0.001);
assert!((center.z - 50.0).abs() < 0.001);
}
#[test]
fn test_placed_box_with_cog_offset() {
let b = PlacedBox::new(
"B1",
0,
Point3::new(0.0, 0.0, 0.0),
Vector3::new(100.0, 100.0, 100.0),
)
.with_cog_offset(Vector3::new(10.0, 0.0, -5.0));
let cog = b.center_of_gravity();
assert!((cog.x - 60.0).abs() < 0.001); assert!((cog.y - 50.0).abs() < 0.001);
assert!((cog.z - 45.0).abs() < 0.001); }
#[test]
fn test_box_on_floor_is_stable() {
let analyzer = StabilityAnalyzer::new(StabilityConstraint::FullBase);
let boxes = vec![PlacedBox::new(
"B1",
0,
Point3::new(0.0, 0.0, 0.0),
Vector3::new(100.0, 100.0, 50.0),
)];
let report = analyzer.analyze(&boxes, 0.0);
assert!(report.is_all_stable());
assert_eq!(report.stable_count, 1);
assert!((report.results[0].support_ratio - 1.0).abs() < 0.001);
}
#[test]
fn test_stacked_boxes_full_support() {
let analyzer = StabilityAnalyzer::new(StabilityConstraint::FullBase);
let boxes = vec![
PlacedBox::new(
"B1",
0,
Point3::new(0.0, 0.0, 0.0),
Vector3::new(100.0, 100.0, 50.0),
),
PlacedBox::new(
"B2",
0,
Point3::new(0.0, 0.0, 50.0),
Vector3::new(100.0, 100.0, 50.0),
),
];
let report = analyzer.analyze(&boxes, 0.0);
assert!(report.is_all_stable());
assert_eq!(report.stable_count, 2);
}
#[test]
fn test_stacked_box_partial_support() {
let analyzer = StabilityAnalyzer::new(StabilityConstraint::partial_base(0.5));
let boxes = vec![
PlacedBox::new(
"B1",
0,
Point3::new(0.0, 0.0, 0.0),
Vector3::new(100.0, 100.0, 50.0),
),
PlacedBox::new(
"B2",
0,
Point3::new(50.0, 0.0, 50.0),
Vector3::new(100.0, 100.0, 50.0),
),
];
let report = analyzer.analyze(&boxes, 0.0);
assert!(report.results[0].is_stable); assert!(report.results[1].is_stable); }
#[test]
fn test_unsupported_box_is_unstable() {
let analyzer = StabilityAnalyzer::new(StabilityConstraint::FullBase);
let boxes = vec![
PlacedBox::new(
"B1",
0,
Point3::new(0.0, 0.0, 0.0),
Vector3::new(50.0, 50.0, 50.0),
),
PlacedBox::new(
"B2",
0,
Point3::new(100.0, 100.0, 50.0),
Vector3::new(50.0, 50.0, 50.0),
),
];
let report = analyzer.analyze(&boxes, 0.0);
assert!(!report.is_all_stable());
assert_eq!(report.unstable_count, 1);
assert!(!report.results[1].is_stable);
}
#[test]
fn test_cog_stability_check() {
let analyzer = StabilityAnalyzer::new(StabilityConstraint::CogPolygon);
let boxes = vec![PlacedBox::new(
"B1",
0,
Point3::new(0.0, 0.0, 0.0),
Vector3::new(100.0, 100.0, 50.0),
)];
let report = analyzer.analyze(&boxes, 0.0);
assert!(report.results[0].cog_within_support);
assert!(report.results[0].is_stable);
}
#[test]
fn test_equilibrium_check() {
let analyzer = StabilityAnalyzer::new(StabilityConstraint::static_equilibrium());
let boxes = vec![PlacedBox::new(
"B1",
0,
Point3::new(0.0, 0.0, 0.0),
Vector3::new(100.0, 100.0, 50.0),
)
.with_mass(10.0)];
let report = analyzer.analyze(&boxes, 0.0);
assert!(report.results[0].is_stable);
assert!(report.results[0].force_imbalance < 1.0);
}
#[test]
fn test_stability_report_summary() {
let analyzer = StabilityAnalyzer::new(StabilityConstraint::partial_base(0.8));
let boxes = vec![
PlacedBox::new(
"B1",
0,
Point3::new(0.0, 0.0, 0.0),
Vector3::new(100.0, 100.0, 50.0),
)
.with_mass(5.0),
PlacedBox::new(
"B2",
0,
Point3::new(50.0, 50.0, 50.0),
Vector3::new(100.0, 100.0, 50.0),
)
.with_mass(3.0),
];
let report = analyzer.analyze(&boxes, 0.0);
assert_eq!(report.results.len(), 2);
assert!(report.analysis_time_ms < 1000); }
#[test]
fn test_no_constraint_always_stable() {
let analyzer = StabilityAnalyzer::new(StabilityConstraint::None);
let boxes = vec![
PlacedBox::new(
"B1",
0,
Point3::new(0.0, 0.0, 100.0), Vector3::new(50.0, 50.0, 50.0),
),
];
let report = analyzer.analyze(&boxes, 0.0);
assert!(report.results.is_empty() || report.is_all_stable());
}
#[test]
fn test_face_overlap_computation() {
let analyzer = StabilityAnalyzer::default();
let area1 = analyzer.compute_face_overlap((0.0, 0.0, 10.0, 10.0), (0.0, 0.0, 10.0, 10.0));
assert!((area1 - 100.0).abs() < 0.001);
let area2 = analyzer.compute_face_overlap((0.0, 0.0, 10.0, 10.0), (5.0, 5.0, 15.0, 15.0));
assert!((area2 - 25.0).abs() < 0.001);
let area3 = analyzer.compute_face_overlap((0.0, 0.0, 10.0, 10.0), (20.0, 20.0, 30.0, 30.0));
assert!(area3 < 0.001);
}
}