use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::cut_plan::{CutPlanError, Result};
use super::model::{OneDProblem, OneDSolution, StockLayout1D};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CutPlanPreset1D {
#[default]
ChopSaw,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct EffectiveCosts1D {
pub cut_cost: f64,
pub fence_reset_cost: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
pub struct CutPlanOptions1D {
#[serde(default)]
pub preset: CutPlanPreset1D,
#[serde(default)]
pub cut_cost: Option<f64>,
#[serde(default)]
pub fence_reset_cost: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CutStep1D {
Cut {
position: u32,
piece_name: String,
},
FenceReset {
new_position: u32,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BarCutPlan1D {
pub stock_name: String,
pub bar_index_in_solution: usize,
pub total_cost: f64,
pub num_cuts: usize,
pub num_fence_resets: usize,
pub steps: Vec<CutStep1D>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CutPlanSolution1D {
pub preset: CutPlanPreset1D,
pub effective_costs: EffectiveCosts1D,
pub bar_plans: Vec<BarCutPlan1D>,
pub total_cost: f64,
}
pub fn plan_cuts(
problem: &OneDProblem,
solution: &OneDSolution,
options: &CutPlanOptions1D,
) -> Result<CutPlanSolution1D> {
let effective_costs = resolve_costs(options)?;
let kerf_by_stock: HashMap<&str, u32> =
problem.stock.iter().map(|s| (s.name.as_str(), s.kerf)).collect();
let mut bar_plans = Vec::with_capacity(solution.layouts.len());
let mut total_cost = 0.0_f64;
for (bar_index, layout) in solution.layouts.iter().enumerate() {
let kerf = kerf_by_stock.get(layout.stock_name.as_str()).copied().unwrap_or(0);
let plan = plan_bar(bar_index, layout, kerf, &effective_costs);
total_cost += plan.total_cost;
bar_plans.push(plan);
}
Ok(CutPlanSolution1D { preset: options.preset, effective_costs, bar_plans, total_cost })
}
fn resolve_costs(options: &CutPlanOptions1D) -> Result<EffectiveCosts1D> {
let (default_cut, default_reset) = match options.preset {
CutPlanPreset1D::ChopSaw => (1.0_f64, 0.3_f64),
};
let cut_cost = validate_cost("cut_cost", options.cut_cost.unwrap_or(default_cut))?;
let fence_reset_cost =
validate_cost("fence_reset_cost", options.fence_reset_cost.unwrap_or(default_reset))?;
Ok(EffectiveCosts1D { cut_cost, fence_reset_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)
}
fn plan_bar(
bar_index: usize,
layout: &StockLayout1D,
kerf: u32,
costs: &EffectiveCosts1D,
) -> BarCutPlan1D {
let mut steps = Vec::with_capacity(layout.cuts.len() * 2);
let mut num_cuts = 0_usize;
let mut num_fence_resets = 0_usize;
let mut last_reset: Option<u32> = None;
let mut cursor: u64 = 0;
for cut in &layout.cuts {
if last_reset != Some(cut.length) {
steps.push(CutStep1D::FenceReset { new_position: cut.length });
num_fence_resets += 1;
last_reset = Some(cut.length);
}
cursor = cursor.saturating_add(u64::from(cut.length));
#[allow(clippy::cast_possible_truncation)]
let position = cursor.min(u64::from(u32::MAX)) as u32;
steps.push(CutStep1D::Cut { position, piece_name: cut.name.clone() });
num_cuts += 1;
cursor = cursor.saturating_add(u64::from(kerf));
}
let total_cost =
(num_cuts as f64) * costs.cut_cost + (num_fence_resets as f64) * costs.fence_reset_cost;
BarCutPlan1D {
stock_name: layout.stock_name.clone(),
bar_index_in_solution: bar_index,
total_cost,
num_cuts,
num_fence_resets,
steps,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::one_d::{
CutDemand1D, OneDOptions, OneDProblem, OneDSolution, SolverMetrics1D, Stock1D,
};
fn empty_problem() -> OneDProblem {
OneDProblem {
stock: vec![Stock1D {
name: "bar".to_string(),
length: 100,
kerf: 2,
trim: 0,
cost: 1.0,
available: None,
}],
demands: vec![CutDemand1D { name: "cut".to_string(), length: 10, quantity: 1 }],
}
}
fn empty_solution() -> OneDSolution {
OneDSolution {
algorithm: "test".to_string(),
exact: false,
lower_bound: None,
stock_count: 0,
total_waste: 0,
total_cost: 0.0,
layouts: Vec::new(),
stock_requirements: Vec::new(),
unplaced: Vec::new(),
metrics: SolverMetrics1D {
iterations: 0,
generated_patterns: 0,
enumerated_patterns: 0,
explored_states: 0,
notes: Vec::new(),
},
}
}
#[test]
fn chop_saw_preset_yields_1_cut_cost_and_point_three_fence_reset() {
let options = CutPlanOptions1D::default();
let plan = plan_cuts(&empty_problem(), &empty_solution(), &options)
.expect("empty solution should plan cleanly");
assert_eq!(plan.preset, CutPlanPreset1D::ChopSaw);
assert_eq!(plan.effective_costs.cut_cost, 1.0);
assert_eq!(plan.effective_costs.fence_reset_cost, 0.3);
assert!(plan.bar_plans.is_empty());
assert_eq!(plan.total_cost, 0.0);
}
#[test]
fn invalid_override_is_rejected() {
let options = CutPlanOptions1D {
preset: CutPlanPreset1D::ChopSaw,
cut_cost: Some(-1.0),
fence_reset_cost: None,
};
let result = plan_cuts(&empty_problem(), &empty_solution(), &options);
assert!(matches!(result, Err(CutPlanError::InvalidOptions(_))));
}
#[test]
fn kerf_is_included_in_cut_positions() {
let problem = OneDProblem {
stock: vec![Stock1D {
name: "bar".to_string(),
length: 100,
kerf: 2,
trim: 0,
cost: 1.0,
available: None,
}],
demands: vec![CutDemand1D { name: "rail".to_string(), length: 30, quantity: 3 }],
};
let solution =
crate::one_d::solve_1d(problem.clone(), OneDOptions::default()).expect("solve");
let plan = plan_cuts(&problem, &solution, &CutPlanOptions1D::default()).expect("plan");
assert_eq!(plan.bar_plans.len(), 1);
let bar = &plan.bar_plans[0];
assert_eq!(bar.num_cuts, 3);
assert_eq!(bar.num_fence_resets, 1);
let positions: Vec<u32> = bar
.steps
.iter()
.filter_map(|step| match step {
CutStep1D::Cut { position, .. } => Some(*position),
_ => None,
})
.collect();
assert_eq!(positions, vec![30, 62, 94]);
}
}