use serde::{Deserialize, Serialize};
use crate::cut_plan::{CutPlanError, Result};
use super::model::{Placement2D, SheetLayout2D, TwoDSolution};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CutPlanPreset2D {
#[default]
TableSaw,
PanelSaw,
CncRouter,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct EffectiveCosts2D {
pub cut_cost: f64,
pub rotate_cost: f64,
pub fence_reset_cost: f64,
pub tool_up_down_cost: f64,
pub travel_cost: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
pub struct CutPlanOptions2D {
#[serde(default)]
pub preset: CutPlanPreset2D,
#[serde(default)]
pub cut_cost: Option<f64>,
#[serde(default)]
pub rotate_cost: Option<f64>,
#[serde(default)]
pub fence_reset_cost: Option<f64>,
#[serde(default)]
pub tool_up_down_cost: Option<f64>,
#[serde(default)]
pub travel_cost: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CutAxis {
Vertical,
Horizontal,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CutStep2D {
Cut {
axis: CutAxis,
position: u32,
},
Rotate,
FenceReset {
new_position: u32,
},
ToolUp,
ToolDown,
Travel {
to_x: u32,
to_y: u32,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SheetCutPlan2D {
pub sheet_name: String,
pub sheet_index_in_solution: usize,
pub total_cost: f64,
pub num_cuts: usize,
pub num_rotations: usize,
pub num_fence_resets: usize,
pub num_tool_ups: usize,
pub travel_distance: u64,
pub steps: Vec<CutStep2D>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CutPlanSolution2D {
pub preset: CutPlanPreset2D,
pub effective_costs: EffectiveCosts2D,
pub sheet_plans: Vec<SheetCutPlan2D>,
pub total_cost: f64,
}
pub fn plan_cuts(solution: &TwoDSolution, options: &CutPlanOptions2D) -> Result<CutPlanSolution2D> {
let effective_costs = resolve_costs(options)?;
let mut sheet_plans = Vec::with_capacity(solution.layouts.len());
let mut total_cost = 0.0_f64;
for (index, layout) in solution.layouts.iter().enumerate() {
let plan = plan_sheet(index, layout, options.preset, &effective_costs)?;
total_cost += plan.total_cost;
sheet_plans.push(plan);
}
Ok(CutPlanSolution2D { preset: options.preset, effective_costs, sheet_plans, total_cost })
}
fn plan_sheet(
index: usize,
layout: &SheetLayout2D,
preset: CutPlanPreset2D,
costs: &EffectiveCosts2D,
) -> Result<SheetCutPlan2D> {
if preset == CutPlanPreset2D::CncRouter {
return Ok(emit_router_plan(index, layout, costs));
}
let region_w = layout
.placements
.iter()
.map(|p| p.x.saturating_add(p.width))
.max()
.unwrap_or(0)
.max(layout.width);
let region_h = layout
.placements
.iter()
.map(|p| p.y.saturating_add(p.height))
.max()
.unwrap_or(0)
.max(layout.height);
let tree = reconstruct_cut_tree(&layout.placements, 0, 0, region_w, region_h);
match tree {
Some(tree) => {
let mut steps = Vec::new();
let mut counters = Counters::default();
let mut prev_axis: Option<CutAxis> = None;
let mut prev_position: Option<u32> = None;
emit_guillotine_steps(
&tree,
costs,
&mut steps,
&mut counters,
&mut prev_axis,
&mut prev_position,
);
let total_cost = (counters.cuts as f64) * costs.cut_cost
+ (counters.rotations as f64) * costs.rotate_cost
+ (counters.fence_resets as f64) * costs.fence_reset_cost;
Ok(SheetCutPlan2D {
sheet_name: layout.sheet_name.clone(),
sheet_index_in_solution: index,
total_cost,
num_cuts: counters.cuts,
num_rotations: counters.rotations,
num_fence_resets: counters.fence_resets,
num_tool_ups: 0,
travel_distance: 0,
steps,
})
}
None => {
Err(CutPlanError::NonGuillotineNotCuttable { sheet_name: layout.sheet_name.clone() })
}
}
}
#[derive(Debug)]
enum CutTreeNode {
Leaf,
Empty,
Split {
axis: CutAxis,
position: u32,
first: Box<CutTreeNode>,
second: Box<CutTreeNode>,
},
}
fn reconstruct_cut_tree(
placements: &[Placement2D],
region_x: u32,
region_y: u32,
region_width: u32,
region_height: u32,
) -> Option<CutTreeNode> {
let inside: Vec<&Placement2D> = placements
.iter()
.filter(|p| {
p.x >= region_x
&& p.y >= region_y
&& p.x + p.width <= region_x + region_width
&& p.y + p.height <= region_y + region_height
})
.collect();
if inside.is_empty() {
return Some(CutTreeNode::Empty);
}
if inside.len() == 1 {
let only = inside[0];
if only.x == region_x
&& only.y == region_y
&& only.width == region_width
&& only.height == region_height
{
return Some(CutTreeNode::Leaf);
}
}
let v_candidates = collect_vertical_candidates(&inside);
for cut_x in v_candidates {
if cut_x <= region_x || cut_x >= region_x + region_width {
continue;
}
if inside.iter().all(|p| p.x + p.width <= cut_x || p.x >= cut_x) {
let first = reconstruct_cut_tree(
placements,
region_x,
region_y,
cut_x - region_x,
region_height,
)?;
let second = reconstruct_cut_tree(
placements,
cut_x,
region_y,
region_x + region_width - cut_x,
region_height,
)?;
return Some(CutTreeNode::Split {
axis: CutAxis::Vertical,
position: cut_x,
first: Box::new(first),
second: Box::new(second),
});
}
}
let h_candidates = collect_horizontal_candidates(&inside);
for cut_y in h_candidates {
if cut_y <= region_y || cut_y >= region_y + region_height {
continue;
}
if inside.iter().all(|p| p.y + p.height <= cut_y || p.y >= cut_y) {
let first = reconstruct_cut_tree(
placements,
region_x,
region_y,
region_width,
cut_y - region_y,
)?;
let second = reconstruct_cut_tree(
placements,
region_x,
cut_y,
region_width,
region_y + region_height - cut_y,
)?;
return Some(CutTreeNode::Split {
axis: CutAxis::Horizontal,
position: cut_y,
first: Box::new(first),
second: Box::new(second),
});
}
}
None
}
fn collect_vertical_candidates(placements: &[&Placement2D]) -> Vec<u32> {
let mut out: Vec<u32> = placements.iter().flat_map(|p| [p.x, p.x + p.width]).collect();
out.sort_unstable();
out.dedup();
out
}
fn collect_horizontal_candidates(placements: &[&Placement2D]) -> Vec<u32> {
let mut out: Vec<u32> = placements.iter().flat_map(|p| [p.y, p.y + p.height]).collect();
out.sort_unstable();
out.dedup();
out
}
#[derive(Debug, Default)]
struct Counters {
cuts: usize,
rotations: usize,
fence_resets: usize,
}
fn emit_guillotine_steps(
node: &CutTreeNode,
costs: &EffectiveCosts2D,
steps: &mut Vec<CutStep2D>,
counters: &mut Counters,
prev_axis: &mut Option<CutAxis>,
prev_position: &mut Option<u32>,
) {
match node {
CutTreeNode::Leaf | CutTreeNode::Empty => {
}
CutTreeNode::Split { axis, position, first, second } => {
let rotated = if let Some(prev) = *prev_axis { prev != *axis } else { false };
if rotated {
steps.push(CutStep2D::Rotate);
counters.rotations += 1;
*prev_position = None;
} else if let Some(prev) = *prev_position
&& prev != *position
{
steps.push(CutStep2D::FenceReset { new_position: *position });
counters.fence_resets += 1;
}
steps.push(CutStep2D::Cut { axis: *axis, position: *position });
counters.cuts += 1;
*prev_axis = Some(*axis);
*prev_position = Some(*position);
emit_guillotine_steps(first, costs, steps, counters, prev_axis, prev_position);
emit_guillotine_steps(second, costs, steps, counters, prev_axis, prev_position);
}
}
let _ = costs; }
fn emit_router_plan(
index: usize,
layout: &SheetLayout2D,
costs: &EffectiveCosts2D,
) -> SheetCutPlan2D {
let mut steps = Vec::new();
let mut num_cuts = 0_usize;
let mut num_tool_ups = 0_usize;
let mut travel_distance = 0_u64;
let visit_order = nearest_neighbor_order(&layout.placements, 0, 0);
let mut current_x = 0_u32;
let mut current_y = 0_u32;
let mut tool_down = false;
for &idx in &visit_order {
let p = &layout.placements[idx];
if current_x != p.x || current_y != p.y {
if tool_down {
steps.push(CutStep2D::ToolUp);
num_tool_ups += 1;
tool_down = false;
}
travel_distance =
travel_distance.saturating_add(manhattan(current_x, current_y, p.x, p.y));
steps.push(CutStep2D::Travel { to_x: p.x, to_y: p.y });
}
if !tool_down {
steps.push(CutStep2D::ToolDown);
tool_down = true;
}
steps.push(CutStep2D::Cut { axis: CutAxis::Horizontal, position: p.x + p.width });
num_cuts += 1;
steps.push(CutStep2D::Cut { axis: CutAxis::Vertical, position: p.y + p.height });
num_cuts += 1;
steps.push(CutStep2D::Cut { axis: CutAxis::Horizontal, position: p.x });
num_cuts += 1;
steps.push(CutStep2D::Cut { axis: CutAxis::Vertical, position: p.y });
num_cuts += 1;
current_x = p.x;
current_y = p.y;
}
if tool_down {
steps.push(CutStep2D::ToolUp);
num_tool_ups += 1;
}
let total_cost = (num_cuts as f64) * costs.cut_cost
+ (num_tool_ups as f64) * costs.tool_up_down_cost
+ (travel_distance as f64) * costs.travel_cost;
SheetCutPlan2D {
sheet_name: layout.sheet_name.clone(),
sheet_index_in_solution: index,
total_cost,
num_cuts,
num_rotations: 0,
num_fence_resets: 0,
num_tool_ups,
travel_distance,
steps,
}
}
fn nearest_neighbor_order(placements: &[Placement2D], start_x: u32, start_y: u32) -> Vec<usize> {
let mut visited = vec![false; placements.len()];
let mut order = Vec::with_capacity(placements.len());
let mut cx = start_x;
let mut cy = start_y;
for _ in 0..placements.len() {
let mut best: Option<(usize, u64)> = None;
for (i, p) in placements.iter().enumerate() {
if visited[i] {
continue;
}
let d = manhattan(cx, cy, p.x, p.y);
if best.is_none_or(|(_, bd)| d < bd) {
best = Some((i, d));
}
}
if let Some((i, _)) = best {
visited[i] = true;
order.push(i);
cx = placements[i].x;
cy = placements[i].y;
}
}
order
}
fn manhattan(ax: u32, ay: u32, bx: u32, by: u32) -> u64 {
u64::from(ax.abs_diff(bx)) + u64::from(ay.abs_diff(by))
}
fn resolve_costs(options: &CutPlanOptions2D) -> Result<EffectiveCosts2D> {
let (cut, rotate, fence, tool_up_down, travel) = match options.preset {
CutPlanPreset2D::TableSaw => (1.0, 2.0, 0.5, 0.0, 0.0),
CutPlanPreset2D::PanelSaw => (1.0, 5.0, 0.3, 0.0, 0.0),
CutPlanPreset2D::CncRouter => (1.0, 0.0, 0.0, 0.2, 0.01),
};
let cut_cost = validate_cost("cut_cost", options.cut_cost.unwrap_or(cut))?;
let rotate_cost = validate_cost("rotate_cost", options.rotate_cost.unwrap_or(rotate))?;
let fence_reset_cost =
validate_cost("fence_reset_cost", options.fence_reset_cost.unwrap_or(fence))?;
let tool_up_down_cost =
validate_cost("tool_up_down_cost", options.tool_up_down_cost.unwrap_or(tool_up_down))?;
let travel_cost = validate_cost("travel_cost", options.travel_cost.unwrap_or(travel))?;
Ok(EffectiveCosts2D { cut_cost, rotate_cost, fence_reset_cost, tool_up_down_cost, travel_cost })
}
fn validate_cost(name: &str, value: f64) -> Result<f64> {
if !value.is_finite() || value < 0.0 {
return Err(CutPlanError::InvalidOptions(format!(
"{name} must be a non-negative finite number, got {value}"
)));
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::two_d::{
Placement2D, RectDemand2D, Sheet2D, SolverMetrics2D, TwoDAlgorithm, TwoDOptions,
TwoDProblem, TwoDSolution, solve_2d,
};
#[test]
fn default_options_use_table_saw_preset() {
let options = CutPlanOptions2D::default();
assert_eq!(options.preset, CutPlanPreset2D::TableSaw);
}
#[test]
fn table_saw_preset_defaults() {
let options = CutPlanOptions2D::default();
let costs = resolve_costs(&options).expect("valid defaults");
assert_eq!(costs.cut_cost, 1.0);
assert_eq!(costs.rotate_cost, 2.0);
assert_eq!(costs.fence_reset_cost, 0.5);
assert_eq!(costs.tool_up_down_cost, 0.0);
assert_eq!(costs.travel_cost, 0.0);
}
#[test]
fn panel_saw_preset_defaults() {
let options =
CutPlanOptions2D { preset: CutPlanPreset2D::PanelSaw, ..CutPlanOptions2D::default() };
let costs = resolve_costs(&options).expect("valid defaults");
assert_eq!(costs.rotate_cost, 5.0);
assert_eq!(costs.fence_reset_cost, 0.3);
}
#[test]
fn cnc_router_preset_defaults() {
let options =
CutPlanOptions2D { preset: CutPlanPreset2D::CncRouter, ..CutPlanOptions2D::default() };
let costs = resolve_costs(&options).expect("valid defaults");
assert_eq!(costs.tool_up_down_cost, 0.2);
assert_eq!(costs.travel_cost, 0.01);
assert_eq!(costs.rotate_cost, 0.0);
assert_eq!(costs.fence_reset_cost, 0.0);
}
#[test]
fn override_wins_over_preset_default() {
let options = CutPlanOptions2D {
preset: CutPlanPreset2D::TableSaw,
cut_cost: Some(2.5),
rotate_cost: None,
fence_reset_cost: Some(0.9),
tool_up_down_cost: None,
travel_cost: None,
};
let costs = resolve_costs(&options).expect("valid overrides");
assert_eq!(costs.cut_cost, 2.5);
assert_eq!(costs.rotate_cost, 2.0); assert_eq!(costs.fence_reset_cost, 0.9);
}
#[test]
fn negative_cost_rejected() {
let options = CutPlanOptions2D {
preset: CutPlanPreset2D::TableSaw,
cut_cost: Some(-1.0),
..CutPlanOptions2D::default()
};
let result = resolve_costs(&options);
assert!(matches!(result, Err(CutPlanError::InvalidOptions(_))));
}
#[test]
fn nan_cost_rejected() {
let options = CutPlanOptions2D {
preset: CutPlanPreset2D::TableSaw,
rotate_cost: Some(f64::NAN),
..CutPlanOptions2D::default()
};
let result = resolve_costs(&options);
assert!(matches!(result, Err(CutPlanError::InvalidOptions(_))));
}
#[test]
fn infinite_cost_rejected() {
let options = CutPlanOptions2D {
preset: CutPlanPreset2D::TableSaw,
travel_cost: Some(f64::INFINITY),
..CutPlanOptions2D::default()
};
let result = resolve_costs(&options);
assert!(matches!(result, Err(CutPlanError::InvalidOptions(_))));
}
#[test]
fn single_placement_full_sheet_needs_no_cuts() {
let problem = TwoDProblem {
sheets: vec![Sheet2D {
name: "s".to_string(),
width: 10,
height: 10,
cost: 1.0,
quantity: None,
kerf: 0,
edge_kerf_relief: false,
}],
demands: vec![RectDemand2D {
name: "p".to_string(),
width: 10,
height: 10,
quantity: 1,
can_rotate: false,
}],
};
let solution = solve_2d(problem, TwoDOptions::default()).expect("solve");
let plan = plan_cuts(&solution, &CutPlanOptions2D::default()).expect("plan");
assert_eq!(plan.sheet_plans.len(), 1);
assert!(plan.sheet_plans[0].steps.is_empty());
assert_eq!(plan.sheet_plans[0].total_cost, 0.0);
}
#[test]
fn two_side_by_side_placements_yield_one_vertical_cut() {
let problem = TwoDProblem {
sheets: vec![Sheet2D {
name: "s".to_string(),
width: 10,
height: 5,
cost: 1.0,
quantity: None,
kerf: 0,
edge_kerf_relief: false,
}],
demands: vec![RectDemand2D {
name: "p".to_string(),
width: 5,
height: 5,
quantity: 2,
can_rotate: false,
}],
};
let solution = solve_2d(
problem,
TwoDOptions { algorithm: TwoDAlgorithm::Guillotine, ..Default::default() },
)
.expect("solve");
let plan = plan_cuts(&solution, &CutPlanOptions2D::default()).expect("plan");
let sheet_plan = &plan.sheet_plans[0];
assert_eq!(sheet_plan.num_cuts, 1);
assert_eq!(sheet_plan.num_rotations, 0);
match &sheet_plan.steps[0] {
CutStep2D::Cut { axis: CutAxis::Vertical, position } => {
assert_eq!(*position, 5);
}
other => panic!("expected vertical cut at 5, got {other:?}"),
}
}
#[test]
fn table_saw_rejects_non_guillotine_layout() {
let sheets = vec![Sheet2D {
name: "s".to_string(),
width: 10,
height: 10,
cost: 1.0,
quantity: None,
kerf: 0,
edge_kerf_relief: false,
}];
let pinwheel_placements = vec![
Placement2D { name: "a".to_string(), x: 0, y: 0, width: 6, height: 4, rotated: false },
Placement2D { name: "b".to_string(), x: 6, y: 0, width: 4, height: 6, rotated: false },
Placement2D { name: "c".to_string(), x: 4, y: 6, width: 6, height: 4, rotated: false },
Placement2D { name: "d".to_string(), x: 0, y: 4, width: 4, height: 6, rotated: false },
];
let pinwheel = TwoDSolution::from_layouts(
"hand_built_pinwheel",
false,
&sheets,
vec![(0, pinwheel_placements)],
Vec::new(),
SolverMetrics2D { iterations: 0, explored_states: 0, notes: Vec::new() },
0,
);
let options =
CutPlanOptions2D { preset: CutPlanPreset2D::TableSaw, ..CutPlanOptions2D::default() };
let result = plan_cuts(&pinwheel, &options);
assert!(
matches!(
result,
Err(CutPlanError::NonGuillotineNotCuttable { ref sheet_name }) if sheet_name == "s"
),
"expected NonGuillotineNotCuttable, got {result:?}",
);
}
#[test]
fn cnc_router_handles_non_guillotine_layout() {
let sheets = vec![Sheet2D {
name: "s".to_string(),
width: 10,
height: 10,
cost: 1.0,
quantity: None,
kerf: 0,
edge_kerf_relief: false,
}];
let placements = vec![
Placement2D { name: "a".to_string(), x: 0, y: 0, width: 6, height: 4, rotated: false },
Placement2D { name: "b".to_string(), x: 6, y: 0, width: 4, height: 6, rotated: false },
Placement2D { name: "c".to_string(), x: 4, y: 6, width: 6, height: 4, rotated: false },
Placement2D { name: "d".to_string(), x: 0, y: 4, width: 4, height: 6, rotated: false },
];
let solution = TwoDSolution::from_layouts(
"hand_built_pinwheel",
false,
&sheets,
vec![(0, placements)],
Vec::new(),
SolverMetrics2D { iterations: 0, explored_states: 0, notes: Vec::new() },
0,
);
let options =
CutPlanOptions2D { preset: CutPlanPreset2D::CncRouter, ..CutPlanOptions2D::default() };
let plan = plan_cuts(&solution, &options).expect("CNC router should handle non-guillotine");
let sheet_plan = &plan.sheet_plans[0];
assert_eq!(sheet_plan.num_cuts, 16, "4 placements × 4 edges each");
assert!(sheet_plan.num_tool_ups > 0);
assert!(sheet_plan.travel_distance > 0);
}
#[test]
fn cnc_router_on_guillotine_layout_also_uses_router_path() {
let problem = TwoDProblem {
sheets: vec![Sheet2D {
name: "s".to_string(),
width: 10,
height: 5,
cost: 1.0,
quantity: None,
kerf: 0,
edge_kerf_relief: false,
}],
demands: vec![RectDemand2D {
name: "p".to_string(),
width: 5,
height: 5,
quantity: 2,
can_rotate: false,
}],
};
let solution = solve_2d(
problem,
TwoDOptions { algorithm: TwoDAlgorithm::Guillotine, ..Default::default() },
)
.expect("solve");
let options =
CutPlanOptions2D { preset: CutPlanPreset2D::CncRouter, ..CutPlanOptions2D::default() };
let plan = plan_cuts(&solution, &options).expect("plan");
let sheet_plan = &plan.sheet_plans[0];
assert_eq!(sheet_plan.num_cuts, 8, "2 placements × 4 edges each");
assert!(sheet_plan.num_tool_ups > 0);
}
#[test]
fn cut_plan_reconstructs_layout_with_edge_kerf_relief_overrun() {
use crate::two_d::model::SheetLayout2D;
let layout = SheetLayout2D {
sheet_name: "s".into(),
width: 48,
height: 10,
cost: 1.0,
placements: vec![
Placement2D { name: "a".into(), x: 0, y: 0, width: 24, height: 10, rotated: false },
Placement2D {
name: "b".into(),
x: 25,
y: 0,
width: 24,
height: 10,
rotated: false,
},
],
used_area: 470,
waste_area: 10,
kerf_area: 10,
largest_usable_drop_area: 0,
sum_sq_usable_drop_areas: 0,
};
let solution = TwoDSolution {
algorithm: "test".into(),
guillotine: true,
sheet_count: 1,
total_waste_area: 10,
total_kerf_area: 10,
total_cost: 1.0,
max_usable_drop_area: 0,
total_sum_sq_usable_drop_areas: 0,
layouts: vec![layout],
unplaced: Vec::new(),
metrics: SolverMetrics2D { iterations: 0, explored_states: 0, notes: Vec::new() },
};
let opts =
CutPlanOptions2D { preset: CutPlanPreset2D::TableSaw, ..CutPlanOptions2D::default() };
let plan = plan_cuts(&solution, &opts).expect("cut plan should reconstruct");
assert_eq!(plan.sheet_plans.len(), 1);
assert!(
plan.sheet_plans[0].num_cuts >= 1,
"two-piece layout must produce at least one cut"
);
}
}