use crate::config::CuttingConfig;
use crate::contour::{ContourType, CutContour};
#[derive(Debug)]
pub enum KerfResult {
Compensated(CutContour),
Collapsed {
contour_id: usize,
reason: String,
},
}
pub fn apply_kerf_compensation(contours: &[CutContour], config: &CuttingConfig) -> Vec<KerfResult> {
let half_kerf = config.kerf_width / 2.0;
if half_kerf <= 0.0 {
return contours
.iter()
.map(|c| KerfResult::Compensated(c.clone()))
.collect();
}
contours
.iter()
.map(|contour| compensate_contour(contour, half_kerf))
.collect()
}
fn compensate_contour(contour: &CutContour, half_kerf: f64) -> KerfResult {
let offset_distance = match contour.contour_type {
ContourType::Exterior => half_kerf, ContourType::Interior => -half_kerf, };
let offset_results =
u_nesting_core::geom::offset::offset_polygon(&contour.vertices, offset_distance);
if offset_results.is_empty() {
return KerfResult::Collapsed {
contour_id: contour.id,
reason: format!(
"contour {} collapsed with kerf offset {:.4}",
contour.id, offset_distance
),
};
}
let best = offset_results
.into_iter()
.max_by_key(|ring| ring.len())
.expect("offset_results is non-empty after is_empty check");
let new_perimeter = u_nesting_core::geom::polygon::perimeter(&best);
let new_centroid = u_nesting_core::geom::polygon::centroid(&best).unwrap_or(contour.centroid);
KerfResult::Compensated(CutContour {
id: contour.id,
geometry_id: contour.geometry_id.clone(),
instance: contour.instance,
contour_type: contour.contour_type,
vertices: best,
perimeter: new_perimeter,
centroid: new_centroid,
})
}
pub fn filter_compensated(results: Vec<KerfResult>) -> Vec<CutContour> {
results
.into_iter()
.filter_map(|r| match r {
KerfResult::Compensated(c) => Some(c),
KerfResult::Collapsed { contour_id, reason } => {
log::warn!(
"Kerf compensation: contour {} collapsed — {}",
contour_id,
reason
);
None
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_square(id: usize, size: f64, ct: ContourType) -> CutContour {
let half = size / 2.0;
CutContour {
id,
geometry_id: format!("part{}", id),
instance: 0,
contour_type: ct,
vertices: vec![(-half, -half), (half, -half), (half, half), (-half, half)],
perimeter: 4.0 * size,
centroid: (0.0, 0.0),
}
}
#[test]
fn test_zero_kerf_returns_original() {
let contours = vec![make_square(0, 10.0, ContourType::Exterior)];
let config = CuttingConfig::new().with_kerf_width(0.0);
let results = apply_kerf_compensation(&contours, &config);
assert_eq!(results.len(), 1);
if let KerfResult::Compensated(c) = &results[0] {
assert_eq!(c.vertices.len(), 4);
assert!((c.vertices[0].0 - (-5.0)).abs() < 1e-10);
} else {
panic!("expected Compensated");
}
}
#[test]
fn test_exterior_offset_outward() {
let contours = vec![make_square(0, 10.0, ContourType::Exterior)];
let config = CuttingConfig::new().with_kerf_width(1.0);
let results = apply_kerf_compensation(&contours, &config);
assert_eq!(results.len(), 1);
if let KerfResult::Compensated(c) = &results[0] {
let original_area = u_nesting_core::geom::polygon::signed_area(
&make_square(0, 10.0, ContourType::Exterior).vertices,
)
.abs();
let new_area = u_nesting_core::geom::polygon::signed_area(&c.vertices).abs();
assert!(
new_area > original_area,
"Exterior kerf should expand: new_area={} > original_area={}",
new_area,
original_area
);
} else {
panic!("expected Compensated");
}
}
#[test]
fn test_interior_offset_inward() {
let contours = vec![make_square(0, 10.0, ContourType::Interior)];
let config = CuttingConfig::new().with_kerf_width(1.0);
let results = apply_kerf_compensation(&contours, &config);
assert_eq!(results.len(), 1);
if let KerfResult::Compensated(c) = &results[0] {
let original_area = u_nesting_core::geom::polygon::signed_area(
&make_square(0, 10.0, ContourType::Interior).vertices,
)
.abs();
let new_area = u_nesting_core::geom::polygon::signed_area(&c.vertices).abs();
assert!(
new_area < original_area,
"Interior kerf should shrink: new_area={} < original_area={}",
new_area,
original_area
);
} else {
panic!("expected Compensated");
}
}
#[test]
fn test_small_contour_collapses() {
let contours = vec![make_square(0, 2.0, ContourType::Interior)];
let config = CuttingConfig::new().with_kerf_width(5.0);
let results = apply_kerf_compensation(&contours, &config);
assert_eq!(results.len(), 1);
match &results[0] {
KerfResult::Collapsed { contour_id, .. } => {
assert_eq!(*contour_id, 0);
}
KerfResult::Compensated(_) => panic!("expected Collapsed for tiny contour"),
}
}
#[test]
fn test_filter_compensated() {
let results = vec![
KerfResult::Compensated(make_square(0, 10.0, ContourType::Exterior)),
KerfResult::Collapsed {
contour_id: 1,
reason: "test collapse".to_string(),
},
KerfResult::Compensated(make_square(2, 10.0, ContourType::Exterior)),
];
let filtered = filter_compensated(results);
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].id, 0);
assert_eq!(filtered[1].id, 2);
}
#[test]
fn test_mixed_contour_types() {
let contours = vec![
make_square(0, 20.0, ContourType::Exterior),
make_square(1, 8.0, ContourType::Interior),
];
let config = CuttingConfig::new().with_kerf_width(0.5);
let results = apply_kerf_compensation(&contours, &config);
let compensated = filter_compensated(results);
assert_eq!(compensated.len(), 2);
let ext = &compensated[0];
let ext_area = u_nesting_core::geom::polygon::signed_area(&ext.vertices).abs();
assert!(ext_area > 400.0);
let int = &compensated[1];
let int_area = u_nesting_core::geom::polygon::signed_area(&int.vertices).abs();
assert!(int_area < 64.0); }
#[test]
fn test_perimeter_updated() {
let contours = vec![make_square(0, 10.0, ContourType::Exterior)];
let config = CuttingConfig::new().with_kerf_width(1.0);
let results = apply_kerf_compensation(&contours, &config);
if let KerfResult::Compensated(c) = &results[0] {
assert!(
c.perimeter > 40.0,
"Perimeter should increase: {}",
c.perimeter
);
} else {
panic!("expected Compensated");
}
}
}