use crate::boundary::Boundary2D;
use crate::geometry::Geometry2D;
#[cfg(feature = "milp")]
use crate::nfp::{compute_nfp_mirrored, rotate_nfp, translate_nfp, Nfp};
#[cfg(feature = "milp")]
use u_nesting_core::exact::{ExactConfig, ExactResult};
use u_nesting_core::geometry::{Boundary, Geometry};
use u_nesting_core::solver::Config;
use u_nesting_core::{Placement, SolveResult};
#[cfg(feature = "milp")]
use good_lp::{
constraint, default_solver, variable, Expression, ProblemVariables, Solution, SolverModel,
Variable, WithTimeLimit,
};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;
#[derive(Debug, Clone)]
struct CandidatePosition {
x: f64,
y: f64,
origin_x: f64,
origin_y: f64,
rotation: f64,
rotation_idx: usize,
mirror: bool,
}
#[derive(Debug, Clone)]
struct PieceInfo {
geometry_idx: usize,
instance_num: usize,
id: String,
area: f64,
widths: Vec<f64>,
heights: Vec<f64>,
candidates: Vec<CandidatePosition>,
}
#[derive(Debug, Clone)]
struct NfpCmSolution {
assignments: Vec<(usize, usize)>,
objective: f64,
exact_result: ExactResult,
}
#[cfg(feature = "milp")]
pub fn run_nfp_cm_nesting(
geometries: &[Geometry2D],
boundary: &Boundary2D,
config: &Config,
exact_config: &ExactConfig,
cancelled: Arc<AtomicBool>,
) -> SolveResult<f64> {
let start = Instant::now();
let mut result = SolveResult::new();
let total_instances: usize = geometries.iter().map(|g| g.quantity()).sum();
if !exact_config.is_within_limit(total_instances) {
log::warn!(
"Instance count {} exceeds exact limit {}",
total_instances,
exact_config.max_items
);
result.computation_time_ms = start.elapsed().as_millis() as u64;
return result;
}
let (b_min, b_max) = boundary.aabb();
let margin = config.margin;
let bound_width = b_max[0] - b_min[0] - 2.0 * margin;
let bound_height = b_max[1] - b_min[1] - 2.0 * margin;
if bound_width <= 0.0 || bound_height <= 0.0 {
log::error!("Invalid boundary dimensions");
result.computation_time_ms = start.elapsed().as_millis() as u64;
return result;
}
let rotation_angles = exact_config.rotation_angles();
let grid_step = exact_config.grid_step;
let pieces = build_piece_info(
geometries,
boundary,
config,
&rotation_angles,
grid_step,
&cancelled,
);
if pieces.is_empty() {
result.computation_time_ms = start.elapsed().as_millis() as u64;
return result;
}
let conflicts = compute_conflicts(
&pieces,
geometries,
config.spacing,
&cancelled,
start,
exact_config.time_limit_ms,
);
if cancelled.load(Ordering::Relaxed) {
result.computation_time_ms = start.elapsed().as_millis() as u64;
return result;
}
match solve_nfp_cm_milp(
&pieces,
&conflicts,
bound_width,
b_min[0] + margin,
b_min[1] + margin,
&rotation_angles,
exact_config,
&cancelled,
start,
) {
Some(solution) => {
for (piece_idx, candidate_idx) in &solution.assignments {
let piece = &pieces[*piece_idx];
let candidate = &piece.candidates[*candidate_idx];
result.placements.push(
Placement::new_2d(
piece.id.clone(),
piece.instance_num,
candidate.origin_x,
candidate.origin_y,
candidate.rotation,
)
.with_mirrored(candidate.mirror),
);
}
result.boundaries_used = if result.placements.is_empty() { 0 } else { 1 };
result.utilization =
pieces.iter().map(|p| p.area).sum::<f64>() / (bound_width * bound_height);
result.best_fitness = Some(solution.objective);
result.strategy = Some("NfpCm".to_string());
result.iterations = Some(solution.exact_result.iterations);
if solution.exact_result.is_optimal {
log::info!("NFP-CM found optimal solution");
}
}
None => {
log::warn!("NFP-CM solver failed");
for piece in &pieces {
result.unplaced.push(piece.id.clone());
}
}
}
result.computation_time_ms = start.elapsed().as_millis() as u64;
result
}
fn build_piece_info(
geometries: &[Geometry2D],
boundary: &Boundary2D,
config: &Config,
rotation_angles: &[f64],
grid_step: f64,
cancelled: &Arc<AtomicBool>,
) -> Vec<PieceInfo> {
let (b_min, b_max) = boundary.aabb();
let margin = config.margin;
let mut pieces = Vec::new();
for (geom_idx, geom) in geometries.iter().enumerate() {
if cancelled.load(Ordering::Relaxed) {
return pieces;
}
let mut widths = Vec::new();
let mut heights = Vec::new();
for &angle in rotation_angles {
let (g_min, g_max) = geom.aabb_at_rotation(angle);
widths.push(g_max[0] - g_min[0]);
heights.push(g_max[1] - g_min[1]);
}
let mirror_candidates: &[bool] = if geom.allow_flip() {
&[false, true]
} else {
&[false]
};
for instance in 0..geom.quantity() {
let mut candidates = Vec::new();
for (rot_idx, &angle) in rotation_angles.iter().enumerate() {
let w = widths[rot_idx];
let h = heights[rot_idx];
let min_x = b_min[0] + margin;
let max_x = b_max[0] - margin - w;
let min_y = b_min[1] + margin;
let max_y = b_max[1] - margin - h;
if max_x < min_x || max_y < min_y {
continue; }
let origin_offsets: Vec<(bool, [f64; 2])> = mirror_candidates
.iter()
.map(|&mirror| {
let (g_min, _) = geom.aabb_at_rotation_mirrored(angle, mirror);
(mirror, g_min)
})
.collect();
let mut x = min_x;
while x <= max_x {
let mut y = min_y;
while y <= max_y {
for &(mirror, g_min) in &origin_offsets {
candidates.push(CandidatePosition {
x,
y,
origin_x: x - g_min[0],
origin_y: y - g_min[1],
rotation: angle,
rotation_idx: rot_idx,
mirror,
});
}
y += grid_step;
}
x += grid_step;
}
}
if candidates.len() > 1000 {
let step = candidates.len() / 1000;
candidates = candidates.into_iter().step_by(step).collect();
}
if !candidates.is_empty() {
pieces.push(PieceInfo {
geometry_idx: geom_idx,
instance_num: instance,
id: geom.id().to_string(),
area: geom.measure(),
widths: widths.clone(),
heights: heights.clone(),
candidates,
});
}
}
}
pieces
}
type Conflict = ((usize, usize), (usize, usize));
type NfpCacheKey = (usize, usize, usize, usize, bool, bool);
fn compute_conflicts(
pieces: &[PieceInfo],
geometries: &[Geometry2D],
spacing: f64,
cancelled: &Arc<AtomicBool>,
start: Instant,
time_limit_ms: u64,
) -> Vec<Conflict> {
let mut conflicts = Vec::new();
let mut nfp_cache: HashMap<NfpCacheKey, Option<Nfp>> = HashMap::new();
for i in 0..pieces.len() {
for j in (i + 1)..pieces.len() {
if cancelled.load(Ordering::Relaxed) {
return conflicts;
}
if start.elapsed().as_millis() as u64 > time_limit_ms / 4 {
log::warn!("Conflict computation taking too long, using simplified model");
return compute_aabb_conflicts(pieces);
}
let geom_i = &geometries[pieces[i].geometry_idx];
let geom_j = &geometries[pieces[j].geometry_idx];
for (ci, cand_i) in pieces[i].candidates.iter().enumerate() {
for (cj, cand_j) in pieces[j].candidates.iter().enumerate() {
let cache_key = (
pieces[i].geometry_idx,
pieces[j].geometry_idx,
cand_i.rotation_idx,
cand_j.rotation_idx,
cand_i.mirror,
cand_j.mirror,
);
let nfp_opt = nfp_cache.entry(cache_key).or_insert_with(|| {
compute_nfp_mirrored(
geom_i,
geom_j,
cand_j.rotation - cand_i.rotation,
cand_i.mirror,
cand_j.mirror,
)
.ok()
});
let overlaps = if let Some(nfp) = nfp_opt {
let absolute_nfp = translate_nfp(
&rotate_nfp(nfp, cand_i.rotation),
(cand_i.origin_x, cand_i.origin_y),
);
point_in_nfp_with_spacing(
&absolute_nfp,
cand_j.origin_x,
cand_j.origin_y,
spacing,
)
} else {
aabb_overlap(
cand_i.x,
cand_i.y,
pieces[i].widths[cand_i.rotation_idx],
pieces[i].heights[cand_i.rotation_idx],
cand_j.x,
cand_j.y,
pieces[j].widths[cand_j.rotation_idx],
pieces[j].heights[cand_j.rotation_idx],
spacing,
)
};
if overlaps {
conflicts.push(((i, ci), (j, cj)));
}
}
}
}
}
conflicts
}
fn compute_aabb_conflicts(pieces: &[PieceInfo]) -> Vec<Conflict> {
let mut conflicts = Vec::new();
for i in 0..pieces.len() {
for j in (i + 1)..pieces.len() {
for (ci, cand_i) in pieces[i].candidates.iter().enumerate() {
for (cj, cand_j) in pieces[j].candidates.iter().enumerate() {
if aabb_overlap(
cand_i.x,
cand_i.y,
pieces[i].widths[cand_i.rotation_idx],
pieces[i].heights[cand_i.rotation_idx],
cand_j.x,
cand_j.y,
pieces[j].widths[cand_j.rotation_idx],
pieces[j].heights[cand_j.rotation_idx],
0.0,
) {
conflicts.push(((i, ci), (j, cj)));
}
}
}
}
}
conflicts
}
fn aabb_overlap(
x1: f64,
y1: f64,
w1: f64,
h1: f64,
x2: f64,
y2: f64,
w2: f64,
h2: f64,
spacing: f64,
) -> bool {
let overlap_x = x1 < x2 + w2 + spacing && x2 < x1 + w1 + spacing;
let overlap_y = y1 < y2 + h2 + spacing && y2 < y1 + h1 + spacing;
overlap_x && overlap_y
}
fn point_in_nfp_with_spacing(nfp: &Nfp, x: f64, y: f64, spacing: f64) -> bool {
for polygon in &nfp.polygons {
if point_in_polygon(x, y, polygon, spacing) {
return true;
}
}
false
}
fn point_in_polygon(x: f64, y: f64, polygon: &[(f64, f64)], buffer: f64) -> bool {
if polygon.len() < 3 {
return false;
}
let mut inside = false;
let n = polygon.len();
for i in 0..n {
let j = (i + 1) % n;
let (xi, yi) = polygon[i];
let (xj, yj) = polygon[j];
let xi = xi - buffer.copysign(xi);
let xj = xj - buffer.copysign(xj);
let yi = yi - buffer.copysign(yi);
let yj = yj - buffer.copysign(yj);
if ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi) {
inside = !inside;
}
}
inside
}
#[cfg(feature = "milp")]
fn solve_nfp_cm_milp(
pieces: &[PieceInfo],
conflicts: &[Conflict],
bound_width: f64,
origin_x: f64,
_origin_y: f64,
_rotation_angles: &[f64],
config: &ExactConfig,
cancelled: &Arc<AtomicBool>,
_start: Instant,
) -> Option<NfpCmSolution> {
let n = pieces.len();
let mut vars = ProblemVariables::new();
let z: Vec<Vec<Variable>> = pieces
.iter()
.enumerate()
.map(|(i, piece)| {
piece
.candidates
.iter()
.enumerate()
.map(|(c, _)| vars.add(variable().binary().name(format!("z_{}_{}", i, c))))
.collect()
})
.collect();
let strip_length = vars.add(variable().min(0.0).max(bound_width).name("strip_length"));
let mut problem = vars
.minimise(strip_length)
.using(default_solver)
.with_time_limit(config.time_limit_ms as f64 / 1000.0);
for (i, _piece) in pieces.iter().enumerate() {
if cancelled.load(Ordering::Relaxed) {
return None;
}
let sum: Expression = z[i].iter().map(|&v| Expression::from(v)).sum();
problem = problem.with(constraint!(sum == 1.0));
}
for (i, piece) in pieces.iter().enumerate() {
for (c, cand) in piece.candidates.iter().enumerate() {
let w = piece.widths[cand.rotation_idx];
let x_rel = cand.x - origin_x; let big_m = bound_width * 2.0;
problem = problem.with(constraint!(
x_rel + w - strip_length <= big_m * (1.0 - z[i][c])
));
}
}
for ((i, ci), (j, cj)) in conflicts {
problem = problem.with(constraint!(z[*i][*ci] + z[*j][*cj] <= 1.0));
}
log::info!(
"Solving NFP-CM MILP with {} pieces, {} candidates, {} conflicts",
n,
pieces.iter().map(|p| p.candidates.len()).sum::<usize>(),
conflicts.len()
);
match problem.solve() {
Ok(solution) => {
let obj_value = solution.value(strip_length);
let mut assignments = Vec::new();
for (i, piece) in pieces.iter().enumerate() {
for (c, _) in piece.candidates.iter().enumerate() {
if solution.value(z[i][c]) > 0.5 {
assignments.push((i, c));
break;
}
}
}
let exact_result = ExactResult::optimal(obj_value);
Some(NfpCmSolution {
assignments,
objective: obj_value,
exact_result,
})
}
Err(e) => {
log::error!("NFP-CM MILP solver error: {:?}", e);
None
}
}
}
#[cfg(not(feature = "milp"))]
pub fn run_nfp_cm_nesting(
geometries: &[Geometry2D],
boundary: &Boundary2D,
_config: &Config,
_exact_config: &ExactConfig,
_cancelled: Arc<AtomicBool>,
) -> SolveResult<f64> {
log::warn!("NFP-CM solver not available (compile with 'milp' feature)");
let mut result = SolveResult::new();
for geom in geometries {
for _ in 0..geom.quantity() {
result.unplaced.push(geom.id().to_string());
}
}
result.strategy = Some("NfpCm (disabled)".to_string());
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_aabb_overlap() {
assert!(aabb_overlap(
0.0, 0.0, 10.0, 10.0, 5.0, 5.0, 10.0, 10.0, 0.0
));
assert!(!aabb_overlap(
0.0, 0.0, 10.0, 10.0, 20.0, 20.0, 10.0, 10.0, 0.0
));
assert!(aabb_overlap(
0.0, 0.0, 10.0, 10.0, 10.0, 0.0, 10.0, 10.0, 1.0
));
}
#[test]
fn test_point_in_polygon() {
let square = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
assert!(point_in_polygon(5.0, 5.0, &square, 0.0));
assert!(!point_in_polygon(15.0, 5.0, &square, 0.0));
}
#[test]
#[cfg(feature = "milp")]
fn test_nfp_cm_simple() {
let geometries = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(2)];
let boundary = Boundary2D::rectangle(50.0, 50.0);
let config = Config::default();
let exact_config = ExactConfig::default()
.with_time_limit_ms(10000)
.with_rotation_steps(1)
.with_grid_step(5.0);
let cancelled = Arc::new(AtomicBool::new(false));
let result = run_nfp_cm_nesting(&geometries, &boundary, &config, &exact_config, cancelled);
assert!(!result.placements.is_empty());
}
#[test]
fn test_build_piece_info_mirror_candidates_only_when_allowed() {
let boundary = Boundary2D::rectangle(50.0, 50.0);
let config = Config::default();
let rotation_angles = vec![0.0];
let cancelled = Arc::new(AtomicBool::new(false));
let plain = vec![Geometry2D::rectangle("R1", 10.0, 10.0).with_quantity(1)];
let pieces = build_piece_info(
&plain,
&boundary,
&config,
&rotation_angles,
10.0,
&cancelled,
);
assert!(
pieces[0].candidates.iter().all(|c| !c.mirror),
"no candidate should be tagged mirrored without allow_flip"
);
let flippable = vec![Geometry2D::rectangle("R1", 10.0, 10.0)
.with_flip(true)
.with_quantity(1)];
let pieces = build_piece_info(
&flippable,
&boundary,
&config,
&rotation_angles,
10.0,
&cancelled,
);
assert!(
pieces[0].candidates.iter().any(|c| c.mirror),
"allow_flip must produce at least one mirrored candidate"
);
assert!(
pieces[0].candidates.iter().any(|c| !c.mirror),
"allow_flip must still keep the unmirrored candidates too"
);
}
fn chiral_l(id: &str) -> Geometry2D {
Geometry2D::l_shape(id, 30.0, 20.0, 20.0, 10.0)
}
fn polygons_overlap(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
for i in 0..a.len() {
let (a1, a2) = (a[i], a[(i + 1) % a.len()]);
for j in 0..b.len() {
let (b1, b2) = (b[j], b[(j + 1) % b.len()]);
if crate::polygon_ops::segments_intersect(a1, a2, b1, b2) {
return true;
}
}
}
false
}
#[test]
#[cfg(feature = "milp")]
fn test_nfp_cm_mirror_no_overlap() {
use crate::nfp::PlacedGeometry;
let geometries = vec![chiral_l("L").with_flip(true).with_quantity(2)];
let boundary = Boundary2D::rectangle(65.0, 45.0);
let config = Config::default().with_spacing(1.0);
let exact_config = ExactConfig::default()
.with_time_limit_ms(15000)
.with_rotation_steps(1)
.with_grid_step(5.0);
let cancelled = Arc::new(AtomicBool::new(false));
let result = run_nfp_cm_nesting(&geometries, &boundary, &config, &exact_config, cancelled);
assert_eq!(
result.placements.len(),
2,
"both instances should fit in this boundary"
);
let polys: Vec<Vec<(f64, f64)>> = result
.placements
.iter()
.map(|p| {
PlacedGeometry::new(geometries[0].clone(), (p.x(), p.y()), p.angle())
.with_mirrored(p.mirrored)
.translated_exterior()
})
.collect();
assert!(
!polygons_overlap(&polys[0], &polys[1]),
"placements must not overlap regardless of mirror state (mirrored: {}, {})",
result.placements[0].mirrored,
result.placements[1].mirrored
);
}
}