1use serde::{Deserialize, Serialize};
8
9use crate::cut_plan::{CutPlanError, Result};
10
11use super::model::{Placement2D, SheetLayout2D, TwoDSolution};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
15#[serde(rename_all = "snake_case")]
16pub enum CutPlanPreset2D {
17 #[default]
20 TableSaw,
21 PanelSaw,
24 CncRouter,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
31pub struct EffectiveCosts2D {
32 pub cut_cost: f64,
34 pub rotate_cost: f64,
36 pub fence_reset_cost: f64,
38 pub tool_up_down_cost: f64,
40 pub travel_cost: f64,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
46pub struct CutPlanOptions2D {
47 #[serde(default)]
49 pub preset: CutPlanPreset2D,
50 #[serde(default)]
52 pub cut_cost: Option<f64>,
53 #[serde(default)]
55 pub rotate_cost: Option<f64>,
56 #[serde(default)]
58 pub fence_reset_cost: Option<f64>,
59 #[serde(default)]
61 pub tool_up_down_cost: Option<f64>,
62 #[serde(default)]
64 pub travel_cost: Option<f64>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum CutAxis {
71 Vertical,
73 Horizontal,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(tag = "kind", rename_all = "snake_case")]
80pub enum CutStep2D {
81 Cut {
84 axis: CutAxis,
86 position: u32,
88 },
89 Rotate,
91 FenceReset {
93 new_position: u32,
95 },
96 ToolUp,
98 ToolDown,
100 Travel {
103 to_x: u32,
105 to_y: u32,
107 },
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct SheetCutPlan2D {
113 pub sheet_name: String,
115 pub sheet_index_in_solution: usize,
117 pub total_cost: f64,
119 pub num_cuts: usize,
121 pub num_rotations: usize,
123 pub num_fence_resets: usize,
125 pub num_tool_ups: usize,
127 pub travel_distance: u64,
129 pub steps: Vec<CutStep2D>,
131}
132
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct CutPlanSolution2D {
136 pub preset: CutPlanPreset2D,
138 pub effective_costs: EffectiveCosts2D,
140 pub sheet_plans: Vec<SheetCutPlan2D>,
142 pub total_cost: f64,
144}
145
146pub fn plan_cuts(solution: &TwoDSolution, options: &CutPlanOptions2D) -> Result<CutPlanSolution2D> {
156 let effective_costs = resolve_costs(options)?;
157
158 let mut sheet_plans = Vec::with_capacity(solution.layouts.len());
159 let mut total_cost = 0.0_f64;
160
161 for (index, layout) in solution.layouts.iter().enumerate() {
162 let plan = plan_sheet(index, layout, options.preset, &effective_costs)?;
163 total_cost += plan.total_cost;
164 sheet_plans.push(plan);
165 }
166
167 Ok(CutPlanSolution2D { preset: options.preset, effective_costs, sheet_plans, total_cost })
168}
169
170fn plan_sheet(
171 index: usize,
172 layout: &SheetLayout2D,
173 preset: CutPlanPreset2D,
174 costs: &EffectiveCosts2D,
175) -> Result<SheetCutPlan2D> {
176 if preset == CutPlanPreset2D::CncRouter {
180 return Ok(emit_router_plan(index, layout, costs));
181 }
182
183 let region_w = layout
188 .placements
189 .iter()
190 .map(|p| p.x.saturating_add(p.width))
191 .max()
192 .unwrap_or(0)
193 .max(layout.width);
194 let region_h = layout
195 .placements
196 .iter()
197 .map(|p| p.y.saturating_add(p.height))
198 .max()
199 .unwrap_or(0)
200 .max(layout.height);
201 let tree = reconstruct_cut_tree(&layout.placements, 0, 0, region_w, region_h);
202 match tree {
203 Some(tree) => {
204 let mut steps = Vec::new();
205 let mut counters = Counters::default();
206 let mut prev_axis: Option<CutAxis> = None;
207 let mut prev_position: Option<u32> = None;
208 emit_guillotine_steps(
209 &tree,
210 costs,
211 &mut steps,
212 &mut counters,
213 &mut prev_axis,
214 &mut prev_position,
215 );
216 let total_cost = (counters.cuts as f64) * costs.cut_cost
217 + (counters.rotations as f64) * costs.rotate_cost
218 + (counters.fence_resets as f64) * costs.fence_reset_cost;
219 Ok(SheetCutPlan2D {
220 sheet_name: layout.sheet_name.clone(),
221 sheet_index_in_solution: index,
222 total_cost,
223 num_cuts: counters.cuts,
224 num_rotations: counters.rotations,
225 num_fence_resets: counters.fence_resets,
226 num_tool_ups: 0,
227 travel_distance: 0,
228 steps,
229 })
230 }
231 None => {
232 Err(CutPlanError::NonGuillotineNotCuttable { sheet_name: layout.sheet_name.clone() })
233 }
234 }
235}
236
237#[derive(Debug)]
243enum CutTreeNode {
244 Leaf,
247 Empty,
249 Split {
251 axis: CutAxis,
253 position: u32,
255 first: Box<CutTreeNode>,
257 second: Box<CutTreeNode>,
259 },
260}
261
262fn reconstruct_cut_tree(
265 placements: &[Placement2D],
266 region_x: u32,
267 region_y: u32,
268 region_width: u32,
269 region_height: u32,
270) -> Option<CutTreeNode> {
271 let inside: Vec<&Placement2D> = placements
273 .iter()
274 .filter(|p| {
275 p.x >= region_x
276 && p.y >= region_y
277 && p.x + p.width <= region_x + region_width
278 && p.y + p.height <= region_y + region_height
279 })
280 .collect();
281
282 if inside.is_empty() {
283 return Some(CutTreeNode::Empty);
284 }
285
286 if inside.len() == 1 {
287 let only = inside[0];
288 if only.x == region_x
290 && only.y == region_y
291 && only.width == region_width
292 && only.height == region_height
293 {
294 return Some(CutTreeNode::Leaf);
295 }
296 }
300
301 let v_candidates = collect_vertical_candidates(&inside);
303 for cut_x in v_candidates {
304 if cut_x <= region_x || cut_x >= region_x + region_width {
305 continue;
306 }
307 if inside.iter().all(|p| p.x + p.width <= cut_x || p.x >= cut_x) {
310 let first = reconstruct_cut_tree(
311 placements,
312 region_x,
313 region_y,
314 cut_x - region_x,
315 region_height,
316 )?;
317 let second = reconstruct_cut_tree(
318 placements,
319 cut_x,
320 region_y,
321 region_x + region_width - cut_x,
322 region_height,
323 )?;
324 return Some(CutTreeNode::Split {
325 axis: CutAxis::Vertical,
326 position: cut_x,
327 first: Box::new(first),
328 second: Box::new(second),
329 });
330 }
331 }
332
333 let h_candidates = collect_horizontal_candidates(&inside);
335 for cut_y in h_candidates {
336 if cut_y <= region_y || cut_y >= region_y + region_height {
337 continue;
338 }
339 if inside.iter().all(|p| p.y + p.height <= cut_y || p.y >= cut_y) {
340 let first = reconstruct_cut_tree(
341 placements,
342 region_x,
343 region_y,
344 region_width,
345 cut_y - region_y,
346 )?;
347 let second = reconstruct_cut_tree(
348 placements,
349 region_x,
350 cut_y,
351 region_width,
352 region_y + region_height - cut_y,
353 )?;
354 return Some(CutTreeNode::Split {
355 axis: CutAxis::Horizontal,
356 position: cut_y,
357 first: Box::new(first),
358 second: Box::new(second),
359 });
360 }
361 }
362
363 None
365}
366
367fn collect_vertical_candidates(placements: &[&Placement2D]) -> Vec<u32> {
368 let mut out: Vec<u32> = placements.iter().flat_map(|p| [p.x, p.x + p.width]).collect();
369 out.sort_unstable();
370 out.dedup();
371 out
372}
373
374fn collect_horizontal_candidates(placements: &[&Placement2D]) -> Vec<u32> {
375 let mut out: Vec<u32> = placements.iter().flat_map(|p| [p.y, p.y + p.height]).collect();
376 out.sort_unstable();
377 out.dedup();
378 out
379}
380
381#[derive(Debug, Default)]
387struct Counters {
388 cuts: usize,
390 rotations: usize,
392 fence_resets: usize,
394}
395
396fn emit_guillotine_steps(
403 node: &CutTreeNode,
404 costs: &EffectiveCosts2D,
405 steps: &mut Vec<CutStep2D>,
406 counters: &mut Counters,
407 prev_axis: &mut Option<CutAxis>,
408 prev_position: &mut Option<u32>,
409) {
410 match node {
411 CutTreeNode::Leaf | CutTreeNode::Empty => {
412 }
414 CutTreeNode::Split { axis, position, first, second } => {
415 let rotated = if let Some(prev) = *prev_axis { prev != *axis } else { false };
416
417 if rotated {
418 steps.push(CutStep2D::Rotate);
419 counters.rotations += 1;
420 *prev_position = None;
423 } else if let Some(prev) = *prev_position
424 && prev != *position
425 {
426 steps.push(CutStep2D::FenceReset { new_position: *position });
427 counters.fence_resets += 1;
428 }
429
430 steps.push(CutStep2D::Cut { axis: *axis, position: *position });
431 counters.cuts += 1;
432 *prev_axis = Some(*axis);
433 *prev_position = Some(*position);
434
435 emit_guillotine_steps(first, costs, steps, counters, prev_axis, prev_position);
436 emit_guillotine_steps(second, costs, steps, counters, prev_axis, prev_position);
437 }
438 }
439 let _ = costs; }
441
442fn emit_router_plan(
453 index: usize,
454 layout: &SheetLayout2D,
455 costs: &EffectiveCosts2D,
456) -> SheetCutPlan2D {
457 let mut steps = Vec::new();
458 let mut num_cuts = 0_usize;
459 let mut num_tool_ups = 0_usize;
460 let mut travel_distance = 0_u64;
461
462 let visit_order = nearest_neighbor_order(&layout.placements, 0, 0);
463
464 let mut current_x = 0_u32;
465 let mut current_y = 0_u32;
466 let mut tool_down = false;
467
468 for &idx in &visit_order {
469 let p = &layout.placements[idx];
470
471 if current_x != p.x || current_y != p.y {
473 if tool_down {
474 steps.push(CutStep2D::ToolUp);
475 num_tool_ups += 1;
476 tool_down = false;
477 }
478 travel_distance =
479 travel_distance.saturating_add(manhattan(current_x, current_y, p.x, p.y));
480 steps.push(CutStep2D::Travel { to_x: p.x, to_y: p.y });
481 }
482
483 if !tool_down {
484 steps.push(CutStep2D::ToolDown);
485 tool_down = true;
486 }
487
488 steps.push(CutStep2D::Cut { axis: CutAxis::Horizontal, position: p.x + p.width });
493 num_cuts += 1;
494
495 steps.push(CutStep2D::Cut { axis: CutAxis::Vertical, position: p.y + p.height });
497 num_cuts += 1;
498
499 steps.push(CutStep2D::Cut { axis: CutAxis::Horizontal, position: p.x });
501 num_cuts += 1;
502
503 steps.push(CutStep2D::Cut { axis: CutAxis::Vertical, position: p.y });
505 num_cuts += 1;
506
507 current_x = p.x;
509 current_y = p.y;
510 }
511
512 if tool_down {
513 steps.push(CutStep2D::ToolUp);
514 num_tool_ups += 1;
515 }
516
517 let total_cost = (num_cuts as f64) * costs.cut_cost
518 + (num_tool_ups as f64) * costs.tool_up_down_cost
519 + (travel_distance as f64) * costs.travel_cost;
520
521 SheetCutPlan2D {
522 sheet_name: layout.sheet_name.clone(),
523 sheet_index_in_solution: index,
524 total_cost,
525 num_cuts,
526 num_rotations: 0,
527 num_fence_resets: 0,
528 num_tool_ups,
529 travel_distance,
530 steps,
531 }
532}
533
534fn nearest_neighbor_order(placements: &[Placement2D], start_x: u32, start_y: u32) -> Vec<usize> {
537 let mut visited = vec![false; placements.len()];
538 let mut order = Vec::with_capacity(placements.len());
539 let mut cx = start_x;
540 let mut cy = start_y;
541
542 for _ in 0..placements.len() {
543 let mut best: Option<(usize, u64)> = None;
544 for (i, p) in placements.iter().enumerate() {
545 if visited[i] {
546 continue;
547 }
548 let d = manhattan(cx, cy, p.x, p.y);
549 if best.is_none_or(|(_, bd)| d < bd) {
550 best = Some((i, d));
551 }
552 }
553 if let Some((i, _)) = best {
554 visited[i] = true;
555 order.push(i);
556 cx = placements[i].x;
557 cy = placements[i].y;
558 }
559 }
560
561 order
562}
563
564fn manhattan(ax: u32, ay: u32, bx: u32, by: u32) -> u64 {
566 u64::from(ax.abs_diff(bx)) + u64::from(ay.abs_diff(by))
567}
568
569fn resolve_costs(options: &CutPlanOptions2D) -> Result<EffectiveCosts2D> {
570 let (cut, rotate, fence, tool_up_down, travel) = match options.preset {
571 CutPlanPreset2D::TableSaw => (1.0, 2.0, 0.5, 0.0, 0.0),
572 CutPlanPreset2D::PanelSaw => (1.0, 5.0, 0.3, 0.0, 0.0),
573 CutPlanPreset2D::CncRouter => (1.0, 0.0, 0.0, 0.2, 0.01),
574 };
575
576 let cut_cost = validate_cost("cut_cost", options.cut_cost.unwrap_or(cut))?;
577 let rotate_cost = validate_cost("rotate_cost", options.rotate_cost.unwrap_or(rotate))?;
578 let fence_reset_cost =
579 validate_cost("fence_reset_cost", options.fence_reset_cost.unwrap_or(fence))?;
580 let tool_up_down_cost =
581 validate_cost("tool_up_down_cost", options.tool_up_down_cost.unwrap_or(tool_up_down))?;
582 let travel_cost = validate_cost("travel_cost", options.travel_cost.unwrap_or(travel))?;
583
584 Ok(EffectiveCosts2D { cut_cost, rotate_cost, fence_reset_cost, tool_up_down_cost, travel_cost })
585}
586
587fn validate_cost(name: &str, value: f64) -> Result<f64> {
588 if !value.is_finite() || value < 0.0 {
589 return Err(CutPlanError::InvalidOptions(format!(
590 "{name} must be a non-negative finite number, got {value}"
591 )));
592 }
593 Ok(value)
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 use crate::two_d::{
601 Placement2D, RectDemand2D, Sheet2D, SolverMetrics2D, TwoDAlgorithm, TwoDOptions,
602 TwoDProblem, TwoDSolution, solve_2d,
603 };
604
605 #[test]
606 fn default_options_use_table_saw_preset() {
607 let options = CutPlanOptions2D::default();
608 assert_eq!(options.preset, CutPlanPreset2D::TableSaw);
609 }
610
611 #[test]
612 fn table_saw_preset_defaults() {
613 let options = CutPlanOptions2D::default();
614 let costs = resolve_costs(&options).expect("valid defaults");
615 assert_eq!(costs.cut_cost, 1.0);
616 assert_eq!(costs.rotate_cost, 2.0);
617 assert_eq!(costs.fence_reset_cost, 0.5);
618 assert_eq!(costs.tool_up_down_cost, 0.0);
619 assert_eq!(costs.travel_cost, 0.0);
620 }
621
622 #[test]
623 fn panel_saw_preset_defaults() {
624 let options =
625 CutPlanOptions2D { preset: CutPlanPreset2D::PanelSaw, ..CutPlanOptions2D::default() };
626 let costs = resolve_costs(&options).expect("valid defaults");
627 assert_eq!(costs.rotate_cost, 5.0);
628 assert_eq!(costs.fence_reset_cost, 0.3);
629 }
630
631 #[test]
632 fn cnc_router_preset_defaults() {
633 let options =
634 CutPlanOptions2D { preset: CutPlanPreset2D::CncRouter, ..CutPlanOptions2D::default() };
635 let costs = resolve_costs(&options).expect("valid defaults");
636 assert_eq!(costs.tool_up_down_cost, 0.2);
637 assert_eq!(costs.travel_cost, 0.01);
638 assert_eq!(costs.rotate_cost, 0.0);
639 assert_eq!(costs.fence_reset_cost, 0.0);
640 }
641
642 #[test]
643 fn override_wins_over_preset_default() {
644 let options = CutPlanOptions2D {
645 preset: CutPlanPreset2D::TableSaw,
646 cut_cost: Some(2.5),
647 rotate_cost: None,
648 fence_reset_cost: Some(0.9),
649 tool_up_down_cost: None,
650 travel_cost: None,
651 };
652 let costs = resolve_costs(&options).expect("valid overrides");
653 assert_eq!(costs.cut_cost, 2.5);
654 assert_eq!(costs.rotate_cost, 2.0); assert_eq!(costs.fence_reset_cost, 0.9);
656 }
657
658 #[test]
659 fn negative_cost_rejected() {
660 let options = CutPlanOptions2D {
661 preset: CutPlanPreset2D::TableSaw,
662 cut_cost: Some(-1.0),
663 ..CutPlanOptions2D::default()
664 };
665 let result = resolve_costs(&options);
666 assert!(matches!(result, Err(CutPlanError::InvalidOptions(_))));
667 }
668
669 #[test]
670 fn nan_cost_rejected() {
671 let options = CutPlanOptions2D {
672 preset: CutPlanPreset2D::TableSaw,
673 rotate_cost: Some(f64::NAN),
674 ..CutPlanOptions2D::default()
675 };
676 let result = resolve_costs(&options);
677 assert!(matches!(result, Err(CutPlanError::InvalidOptions(_))));
678 }
679
680 #[test]
681 fn infinite_cost_rejected() {
682 let options = CutPlanOptions2D {
683 preset: CutPlanPreset2D::TableSaw,
684 travel_cost: Some(f64::INFINITY),
685 ..CutPlanOptions2D::default()
686 };
687 let result = resolve_costs(&options);
688 assert!(matches!(result, Err(CutPlanError::InvalidOptions(_))));
689 }
690
691 #[test]
696 fn single_placement_full_sheet_needs_no_cuts() {
697 let problem = TwoDProblem {
698 sheets: vec![Sheet2D {
699 name: "s".to_string(),
700 width: 10,
701 height: 10,
702 cost: 1.0,
703 quantity: None,
704 kerf: 0,
705 edge_kerf_relief: false,
706 }],
707 demands: vec![RectDemand2D {
708 name: "p".to_string(),
709 width: 10,
710 height: 10,
711 quantity: 1,
712 can_rotate: false,
713 }],
714 };
715 let solution = solve_2d(problem, TwoDOptions::default()).expect("solve");
716 let plan = plan_cuts(&solution, &CutPlanOptions2D::default()).expect("plan");
717 assert_eq!(plan.sheet_plans.len(), 1);
718 assert!(plan.sheet_plans[0].steps.is_empty());
719 assert_eq!(plan.sheet_plans[0].total_cost, 0.0);
720 }
721
722 #[test]
723 fn two_side_by_side_placements_yield_one_vertical_cut() {
724 let problem = TwoDProblem {
725 sheets: vec![Sheet2D {
726 name: "s".to_string(),
727 width: 10,
728 height: 5,
729 cost: 1.0,
730 quantity: None,
731 kerf: 0,
732 edge_kerf_relief: false,
733 }],
734 demands: vec![RectDemand2D {
735 name: "p".to_string(),
736 width: 5,
737 height: 5,
738 quantity: 2,
739 can_rotate: false,
740 }],
741 };
742 let solution = solve_2d(
743 problem,
744 TwoDOptions { algorithm: TwoDAlgorithm::Guillotine, ..Default::default() },
745 )
746 .expect("solve");
747 let plan = plan_cuts(&solution, &CutPlanOptions2D::default()).expect("plan");
748 let sheet_plan = &plan.sheet_plans[0];
749 assert_eq!(sheet_plan.num_cuts, 1);
750 assert_eq!(sheet_plan.num_rotations, 0);
751 match &sheet_plan.steps[0] {
752 CutStep2D::Cut { axis: CutAxis::Vertical, position } => {
753 assert_eq!(*position, 5);
754 }
755 other => panic!("expected vertical cut at 5, got {other:?}"),
756 }
757 }
758
759 #[test]
760 fn table_saw_rejects_non_guillotine_layout() {
761 let sheets = vec![Sheet2D {
762 name: "s".to_string(),
763 width: 10,
764 height: 10,
765 cost: 1.0,
766 quantity: None,
767 kerf: 0,
768 edge_kerf_relief: false,
769 }];
770 let pinwheel_placements = vec![
773 Placement2D { name: "a".to_string(), x: 0, y: 0, width: 6, height: 4, rotated: false },
774 Placement2D { name: "b".to_string(), x: 6, y: 0, width: 4, height: 6, rotated: false },
775 Placement2D { name: "c".to_string(), x: 4, y: 6, width: 6, height: 4, rotated: false },
776 Placement2D { name: "d".to_string(), x: 0, y: 4, width: 4, height: 6, rotated: false },
777 ];
778 let pinwheel = TwoDSolution::from_layouts(
779 "hand_built_pinwheel",
780 false,
781 &sheets,
782 vec![(0, pinwheel_placements)],
783 Vec::new(),
784 SolverMetrics2D { iterations: 0, explored_states: 0, notes: Vec::new() },
785 0,
786 );
787 let options =
788 CutPlanOptions2D { preset: CutPlanPreset2D::TableSaw, ..CutPlanOptions2D::default() };
789 let result = plan_cuts(&pinwheel, &options);
790 assert!(
791 matches!(
792 result,
793 Err(CutPlanError::NonGuillotineNotCuttable { ref sheet_name }) if sheet_name == "s"
794 ),
795 "expected NonGuillotineNotCuttable, got {result:?}",
796 );
797 }
798
799 #[test]
804 fn cnc_router_handles_non_guillotine_layout() {
805 let sheets = vec![Sheet2D {
806 name: "s".to_string(),
807 width: 10,
808 height: 10,
809 cost: 1.0,
810 quantity: None,
811 kerf: 0,
812 edge_kerf_relief: false,
813 }];
814 let placements = vec![
816 Placement2D { name: "a".to_string(), x: 0, y: 0, width: 6, height: 4, rotated: false },
817 Placement2D { name: "b".to_string(), x: 6, y: 0, width: 4, height: 6, rotated: false },
818 Placement2D { name: "c".to_string(), x: 4, y: 6, width: 6, height: 4, rotated: false },
819 Placement2D { name: "d".to_string(), x: 0, y: 4, width: 4, height: 6, rotated: false },
820 ];
821 let solution = TwoDSolution::from_layouts(
822 "hand_built_pinwheel",
823 false,
824 &sheets,
825 vec![(0, placements)],
826 Vec::new(),
827 SolverMetrics2D { iterations: 0, explored_states: 0, notes: Vec::new() },
828 0,
829 );
830 let options =
831 CutPlanOptions2D { preset: CutPlanPreset2D::CncRouter, ..CutPlanOptions2D::default() };
832 let plan = plan_cuts(&solution, &options).expect("CNC router should handle non-guillotine");
833 let sheet_plan = &plan.sheet_plans[0];
834 assert_eq!(sheet_plan.num_cuts, 16, "4 placements × 4 edges each");
835 assert!(sheet_plan.num_tool_ups > 0);
836 assert!(sheet_plan.travel_distance > 0);
837 }
838
839 #[test]
840 fn cnc_router_on_guillotine_layout_also_uses_router_path() {
841 let problem = TwoDProblem {
845 sheets: vec![Sheet2D {
846 name: "s".to_string(),
847 width: 10,
848 height: 5,
849 cost: 1.0,
850 quantity: None,
851 kerf: 0,
852 edge_kerf_relief: false,
853 }],
854 demands: vec![RectDemand2D {
855 name: "p".to_string(),
856 width: 5,
857 height: 5,
858 quantity: 2,
859 can_rotate: false,
860 }],
861 };
862 let solution = solve_2d(
863 problem,
864 TwoDOptions { algorithm: TwoDAlgorithm::Guillotine, ..Default::default() },
865 )
866 .expect("solve");
867 let options =
868 CutPlanOptions2D { preset: CutPlanPreset2D::CncRouter, ..CutPlanOptions2D::default() };
869 let plan = plan_cuts(&solution, &options).expect("plan");
870 let sheet_plan = &plan.sheet_plans[0];
871 assert_eq!(sheet_plan.num_cuts, 8, "2 placements × 4 edges each");
872 assert!(sheet_plan.num_tool_ups > 0);
873 }
874
875 #[test]
876 fn cut_plan_reconstructs_layout_with_edge_kerf_relief_overrun() {
877 use crate::two_d::model::SheetLayout2D;
878
879 let layout = SheetLayout2D {
882 sheet_name: "s".into(),
883 width: 48,
884 height: 10,
885 cost: 1.0,
886 placements: vec![
887 Placement2D { name: "a".into(), x: 0, y: 0, width: 24, height: 10, rotated: false },
888 Placement2D {
889 name: "b".into(),
890 x: 25,
891 y: 0,
892 width: 24,
893 height: 10,
894 rotated: false,
895 },
896 ],
897 used_area: 470,
898 waste_area: 10,
899 kerf_area: 10,
900 largest_usable_drop_area: 0,
901 sum_sq_usable_drop_areas: 0,
902 };
903 let solution = TwoDSolution {
904 algorithm: "test".into(),
905 guillotine: true,
906 sheet_count: 1,
907 total_waste_area: 10,
908 total_kerf_area: 10,
909 total_cost: 1.0,
910 max_usable_drop_area: 0,
911 total_sum_sq_usable_drop_areas: 0,
912 layouts: vec![layout],
913 unplaced: Vec::new(),
914 metrics: SolverMetrics2D { iterations: 0, explored_states: 0, notes: Vec::new() },
915 };
916
917 let opts =
918 CutPlanOptions2D { preset: CutPlanPreset2D::TableSaw, ..CutPlanOptions2D::default() };
919 let plan = plan_cuts(&solution, &opts).expect("cut plan should reconstruct");
920 assert_eq!(plan.sheet_plans.len(), 1);
921 assert!(
922 plan.sheet_plans[0].num_cuts >= 1,
923 "two-piece layout must produce at least one cut"
924 );
925 }
926}