use crate::boundary::Boundary2D;
use crate::geometry::Geometry2D;
#[cfg(feature = "milp")]
use crate::nfp::{compute_nfp_mirrored, rotate_nfp, translate_nfp, Nfp};
use crate::placement_utils::offset_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 };
let placed_area: f64 = solution
.assignments
.iter()
.map(|(piece_idx, _)| pieces[*piece_idx].area)
.sum();
result.utilization = placed_area / (bound_width * bound_height);
let placed: std::collections::HashSet<usize> = solution
.assignments
.iter()
.map(|(piece_idx, _)| *piece_idx)
.collect();
for (idx, piece) in pieces.iter().enumerate() {
if !placed.contains(&idx) {
result.unplaced.push(piece.id.clone());
}
}
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
}
const POSITION_CAP: usize = 64;
fn stacking_positions(extents: &[f64], span: f64, depth: usize, cap: usize) -> Option<Vec<f64>> {
let mut distinct: Vec<f64> = Vec::new();
for &e in extents {
if e > 0.0 && !distinct.iter().any(|d: &f64| (d - e).abs() <= 1e-9) {
distinct.push(e);
}
}
if distinct.is_empty() {
return Some(vec![0.0]);
}
let mut positions = vec![0.0_f64];
let mut frontier = vec![0.0_f64];
for _ in 0..depth {
let mut next = Vec::new();
for &base in &frontier {
for &e in &distinct {
let p = base + e;
if p > span + 1e-9 {
continue;
}
if !positions.iter().any(|q: &f64| (q - p).abs() <= 1e-9) {
positions.push(p);
next.push(p);
}
}
}
if positions.len() > cap {
return None;
}
if next.is_empty() {
break;
}
frontier = next;
}
if span > 1e-9 && !positions.iter().any(|q| (q - span).abs() <= 1e-9) {
positions.push(span);
}
positions.sort_by(|a, b| a.partial_cmp(b).expect("finite positions"));
Some(positions)
}
fn grid_positions(span: f64, step: f64) -> Vec<f64> {
let mut positions = Vec::new();
let mut p = 0.0_f64;
while p <= span + 1e-9 {
positions.push(p);
p += step;
}
if positions.is_empty() {
positions.push(0.0);
}
positions
}
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 all_widths = Vec::new();
let mut all_heights = Vec::new();
for geom in geometries {
for &angle in rotation_angles {
let (g_min, g_max) = geom.aabb_at_rotation(angle);
all_widths.push(g_max[0] - g_min[0]);
all_heights.push(g_max[1] - g_min[1]);
}
}
let stack_depth: usize = geometries
.iter()
.map(|g| g.quantity())
.sum::<usize>()
.max(1);
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 xs = stacking_positions(&all_widths, max_x - min_x, stack_depth, POSITION_CAP)
.unwrap_or_else(|| grid_positions(max_x - min_x, grid_step));
let ys = stacking_positions(&all_heights, max_y - min_y, stack_depth, POSITION_CAP)
.unwrap_or_else(|| grid_positions(max_y - min_y, grid_step));
for &dx in &xs {
let x = min_x + dx;
for &dy in &ys {
let y = min_y + dy;
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,
});
}
}
}
}
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, spacing);
}
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()
.map(|nfp| offset_nfp(&nfp, spacing))
});
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(&absolute_nfp, cand_j.origin_x, cand_j.origin_y)
} 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], spacing: f64) -> 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],
spacing,
) {
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(nfp: &Nfp, x: f64, y: f64) -> bool {
nfp.polygons
.iter()
.any(|polygon| point_in_polygon(x, y, polygon))
}
fn point_in_polygon(x: f64, y: f64, polygon: &[(f64, 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];
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 min_area = pieces
.iter()
.map(|p| p.area)
.fold(f64::INFINITY, f64::min)
.max(f64::MIN_POSITIVE);
let compactness = 0.5 * min_area / (bound_width + 1.0);
let placed_area: Expression = pieces
.iter()
.enumerate()
.flat_map(|(i, piece)| z[i].iter().map(move |&v| piece.area * v))
.sum();
let mut problem = vars
.maximise(placed_area - compactness * 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));
assert!(!point_in_polygon(15.0, 5.0, &square));
}
#[test]
#[cfg(feature = "milp")]
fn two_pieces_that_fit_are_placed_rather_than_nothing() {
let geometries = vec![Geometry2D::rectangle("R", 50.0, 50.0).with_quantity(2)];
let boundary = Boundary2D::rectangle(200.0, 100.0);
let exact_config = ExactConfig::default()
.with_time_limit_ms(60_000)
.with_rotation_steps(1)
.with_grid_step(12.5);
let result = run_nfp_cm_nesting(
&geometries,
&boundary,
&Config::default(),
&exact_config,
Arc::new(AtomicBool::new(false)),
);
assert_eq!(
result.placements.len(),
2,
"unplaced: {:?}",
result.unplaced
);
}
#[test]
#[cfg(feature = "milp")]
fn what_does_not_fit_is_reported_rather_than_making_the_model_infeasible() {
let geometries = vec![Geometry2D::rectangle("R", 50.0, 50.0).with_quantity(3)];
let boundary = Boundary2D::rectangle(60.0, 60.0);
let exact_config = ExactConfig::default()
.with_time_limit_ms(60_000)
.with_rotation_steps(1)
.with_grid_step(5.0);
let result = run_nfp_cm_nesting(
&geometries,
&boundary,
&Config::default(),
&exact_config,
Arc::new(AtomicBool::new(false)),
);
assert_eq!(
result.placements.len(),
1,
"unplaced: {:?}",
result.unplaced
);
assert!(
!result.unplaced.is_empty(),
"the pieces that did not fit are not reported"
);
let expected = 2500.0 / (60.0 * 60.0);
assert!(
(result.utilization - expected).abs() < 1e-9,
"utilization {} should be the one placed piece, {expected}",
result.utilization
);
}
#[test]
#[cfg(feature = "milp")]
fn spacing_is_kept_between_placed_pieces() {
let spacing = 10.0;
let geometries = vec![Geometry2D::rectangle("R", 20.0, 20.0).with_quantity(2)];
let boundary = Boundary2D::rectangle(100.0, 40.0);
let exact_config = ExactConfig::default()
.with_time_limit_ms(60_000)
.with_rotation_steps(1)
.with_grid_step(5.0);
let result = run_nfp_cm_nesting(
&geometries,
&boundary,
&Config::default().with_spacing(spacing),
&exact_config,
Arc::new(AtomicBool::new(false)),
);
assert_eq!(
result.placements.len(),
2,
"unplaced: {:?}",
result.unplaced
);
let a = &result.placements[0];
let b = &result.placements[1];
let gap_x = (a.x() - b.x()).abs() - 20.0;
let gap_y = (a.y() - b.y()).abs() - 20.0;
let gap = gap_x.max(gap_y);
assert!(
gap >= spacing - 1e-6,
"pieces at ({}, {}) and ({}, {}) are {gap} apart, less than the {spacing} asked for",
a.x(),
a.y(),
b.x(),
b.y()
);
}
#[test]
fn stacking_positions_are_the_sums_of_the_extents_that_fit() {
let positions = stacking_positions(&[50.0], 150.0, 2, 64).expect("within the cap");
assert_eq!(positions, vec![0.0, 50.0, 100.0, 150.0]);
}
#[test]
fn stacking_positions_mix_the_extents() {
let positions = stacking_positions(&[20.0, 30.0], 60.0, 3, 64).expect("within the cap");
assert_eq!(positions, vec![0.0, 20.0, 30.0, 40.0, 50.0, 60.0]);
}
#[test]
fn stacking_positions_stop_at_the_number_of_pieces() {
let positions = stacking_positions(&[10.0], 100.0, 3, 64).expect("within the cap");
assert_eq!(positions, vec![0.0, 10.0, 20.0, 30.0, 100.0]);
}
#[test]
fn stacking_positions_give_up_past_the_cap() {
let extents: Vec<f64> = (1..=9).map(|i| i as f64).collect();
assert!(stacking_positions(&extents, 1000.0, 9, 64).is_none());
}
#[test]
#[cfg(feature = "milp")]
fn four_pieces_on_a_long_sheet_are_all_placed() {
let geometries = vec![Geometry2D::rectangle("R", 50.0, 50.0).with_quantity(4)];
let boundary = Boundary2D::rectangle(400.0, 100.0);
let exact_config = ExactConfig::default()
.with_time_limit_ms(60_000)
.with_rotation_steps(4)
.with_grid_step(12.5);
let result = run_nfp_cm_nesting(
&geometries,
&boundary,
&Config::default(),
&exact_config,
Arc::new(AtomicBool::new(false)),
);
assert_eq!(
result.placements.len(),
4,
"unplaced: {:?}",
result.unplaced
);
assert!(result.unplaced.is_empty(), "{:?}", result.unplaced);
}
#[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
);
}
}