Skip to main content

bin_packing/two_d/
cut_plan.rs

1//! 2D cut sequencing: guillotine cut trees for single-blade machines,
2//! tool paths for CNC routers.
3//!
4//! See `docs/superpowers/specs/2026-04-15-cut-sequencer-design.md` for
5//! the design rationale.
6
7use serde::{Deserialize, Serialize};
8
9use crate::cut_plan::{CutPlanError, Result};
10
11use super::model::{Placement2D, SheetLayout2D, TwoDSolution};
12
13/// Preset cost model for a 2D cutting operation.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
15#[serde(rename_all = "snake_case")]
16pub enum CutPlanPreset2D {
17    /// Table saw. Guillotine-compatible layouts only. Costs: cut 1.0,
18    /// rotate 2.0, fence_reset 0.5.
19    #[default]
20    TableSaw,
21    /// Panel saw. Guillotine-compatible layouts only. Costs: cut 1.0,
22    /// rotate 5.0 (rotating a panel is expensive), fence_reset 0.3.
23    PanelSaw,
24    /// CNC router. Accepts both guillotine and non-guillotine layouts.
25    /// Costs: cut 1.0, tool_up_down 0.2, travel 0.01 per unit.
26    CncRouter,
27}
28
29/// Resolved cost values after applying the preset and user overrides.
30#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
31pub struct EffectiveCosts2D {
32    /// Cost of a single cut step.
33    pub cut_cost: f64,
34    /// Cost of rotating the workpiece (table/panel saw only).
35    pub rotate_cost: f64,
36    /// Cost of moving the fence to a new position (table/panel saw only).
37    pub fence_reset_cost: f64,
38    /// Cost of a tool-up or tool-down transition (CNC only).
39    pub tool_up_down_cost: f64,
40    /// Cost per unit of tool travel (CNC only).
41    pub travel_cost: f64,
42}
43
44/// Options controlling how `plan_cuts` scores a 2D plan.
45#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
46pub struct CutPlanOptions2D {
47    /// Cost preset. Defaults to [`CutPlanPreset2D::TableSaw`].
48    #[serde(default)]
49    pub preset: CutPlanPreset2D,
50    /// Override the preset's `cut_cost`.
51    #[serde(default)]
52    pub cut_cost: Option<f64>,
53    /// Override the preset's `rotate_cost` (no effect on CNC router).
54    #[serde(default)]
55    pub rotate_cost: Option<f64>,
56    /// Override the preset's `fence_reset_cost` (no effect on CNC router).
57    #[serde(default)]
58    pub fence_reset_cost: Option<f64>,
59    /// Override the preset's `tool_up_down_cost` (CNC only).
60    #[serde(default)]
61    pub tool_up_down_cost: Option<f64>,
62    /// Override the preset's `travel_cost` (CNC only).
63    #[serde(default)]
64    pub travel_cost: Option<f64>,
65}
66
67/// Axis along which a cut is made.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum CutAxis {
71    /// Vertical cut: the blade runs along the Y axis, separating regions by X.
72    Vertical,
73    /// Horizontal cut: the blade runs along the X axis, separating regions by Y.
74    Horizontal,
75}
76
77/// A single step in a 2D cut plan.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(tag = "kind", rename_all = "snake_case")]
80pub enum CutStep2D {
81    /// Make a cut along `axis` at `position`. For a vertical cut,
82    /// `position` is the x-coordinate; for horizontal, the y-coordinate.
83    Cut {
84        /// Direction the cut runs.
85        axis: CutAxis,
86        /// Coordinate where the cut is placed.
87        position: u32,
88    },
89    /// Rotate the workpiece 90° (table / panel saw only).
90    Rotate,
91    /// Reset the fence to a new position (table / panel saw only).
92    FenceReset {
93        /// New fence setting.
94        new_position: u32,
95    },
96    /// Lift the tool off the workpiece (CNC router only).
97    ToolUp,
98    /// Lower the tool onto the workpiece (CNC router only).
99    ToolDown,
100    /// Travel to a new coordinate with the tool up (CNC router only).
101    /// The start of the travel is the previous step's endpoint.
102    Travel {
103        /// Destination x-coordinate.
104        to_x: u32,
105        /// Destination y-coordinate.
106        to_y: u32,
107    },
108}
109
110/// Cut plan for a single sheet.
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct SheetCutPlan2D {
113    /// Name of the sheet type consumed by this layout.
114    pub sheet_name: String,
115    /// Index of the originating `SheetLayout2D` in `solution.layouts`.
116    pub sheet_index_in_solution: usize,
117    /// Sum of step costs on this sheet.
118    pub total_cost: f64,
119    /// Number of cut steps emitted.
120    pub num_cuts: usize,
121    /// Number of rotation steps emitted.
122    pub num_rotations: usize,
123    /// Number of fence-reset steps emitted.
124    pub num_fence_resets: usize,
125    /// Number of tool-up steps emitted (tool-down count equals tool-up).
126    pub num_tool_ups: usize,
127    /// Total distance traveled with the tool up.
128    pub travel_distance: u64,
129    /// Ordered steps.
130    pub steps: Vec<CutStep2D>,
131}
132
133/// Cut plan for an entire 2D solution.
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct CutPlanSolution2D {
136    /// Preset used to score this plan.
137    pub preset: CutPlanPreset2D,
138    /// Resolved cost values.
139    pub effective_costs: EffectiveCosts2D,
140    /// Per-sheet plans, in `solution.layouts` order.
141    pub sheet_plans: Vec<SheetCutPlan2D>,
142    /// Sum of per-sheet costs.
143    pub total_cost: f64,
144}
145
146/// Generate a cut plan for a finished 2D solution.
147///
148/// # Errors
149///
150/// Returns [`CutPlanError::InvalidOptions`] if any cost override is
151/// negative, NaN, or infinite. Returns
152/// [`CutPlanError::NonGuillotineNotCuttable`] if the preset requires a
153/// single-blade machine (table saw or panel saw) and any sheet layout
154/// is not guillotine-compatible.
155pub 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    // CNC router always uses per-placement outlines regardless of whether
177    // the layout is guillotine-compatible.  Single-blade machines (table
178    // saw / panel saw) require a guillotine layout.
179    if preset == CutPlanPreset2D::CncRouter {
180        return Ok(emit_router_plan(index, layout, costs));
181    }
182
183    // If any placement extends past the layout's declared width/height
184    // (because the source sheet had `edge_kerf_relief` enabled), expand the
185    // root region to enclose them; otherwise the cut-tree reconstructor
186    // would reject those placements as outside the region.
187    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// ---------------------------------------------------------------------------
238// Cut-tree reconstruction
239// ---------------------------------------------------------------------------
240
241/// Internal node of a reconstructed guillotine cut tree.
242#[derive(Debug)]
243enum CutTreeNode {
244    /// A region occupied by a single placement or a single piece of waste that
245    /// needs no further cutting.
246    Leaf,
247    /// A region with no placements (waste / drop).
248    Empty,
249    /// A region split by a guillotine cut into two sub-regions.
250    Split {
251        /// Axis of the cut.
252        axis: CutAxis,
253        /// Coordinate of the cut along the axis.
254        position: u32,
255        /// The first (left / top) sub-region.
256        first: Box<CutTreeNode>,
257        /// The second (right / bottom) sub-region.
258        second: Box<CutTreeNode>,
259    },
260}
261
262/// Try to reconstruct a guillotine cut tree for the placements inside the
263/// given region.  Returns `None` when no guillotine decomposition exists.
264fn 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    // Collect placements that are fully contained within this region.
272    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        // Exactly fills the region → leaf.
289        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        // Single placement doesn't fill the whole region; fall through to
297        // the general cut-finder which will find a cut separating it from
298        // the waste.
299    }
300
301    // Try vertical cuts at every candidate x coordinate.
302    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        // A placement touching the cut line (ending exactly at cut_x) is
308        // on the left; a placement starting at cut_x is on the right.
309        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    // Try horizontal cuts at every candidate y coordinate.
334    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    // No valid guillotine cut found.
364    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// ---------------------------------------------------------------------------
382// Step emission
383// ---------------------------------------------------------------------------
384
385/// Running counters for steps and cost components.
386#[derive(Debug, Default)]
387struct Counters {
388    /// Number of `Cut` steps emitted.
389    cuts: usize,
390    /// Number of `Rotate` steps emitted.
391    rotations: usize,
392    /// Number of `FenceReset` steps emitted.
393    fence_resets: usize,
394}
395
396/// Depth-first traversal of a cut tree, emitting `CutStep2D` values.
397///
398/// Emits `Rotate` before a cut whose axis differs from the previous emitted
399/// cut.  Emits `FenceReset` before a cut whose position differs from the
400/// previous emitted cut **on the same axis** (a `Rotate` resets fence
401/// tracking, so no `FenceReset` is emitted immediately after a `Rotate`).
402fn 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            // No cuts inside a leaf or empty region.
413        }
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                // After a Rotate the fence position context resets; do NOT
421                // emit a FenceReset for the first cut on the new axis.
422                *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; // cost already counted via counters
440}
441
442// ---------------------------------------------------------------------------
443// CNC router path
444// ---------------------------------------------------------------------------
445
446/// Build a per-placement outline tool-path for a CNC router.
447///
448/// Visits placements in nearest-neighbor order from (0, 0).  For each
449/// placement emits `ToolDown`, four `Cut` steps tracing the rectangle
450/// outline, and `ToolUp`.  A `Travel` step is emitted (with `ToolUp`
451/// before it) whenever the router must move between placements.
452fn 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        // Move to the placement's top-left corner if not already there.
472        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        // Trace the four edges of the rectangle as Cut steps.
489        // The tool ends back at (p.x, p.y) after the full outline.
490
491        // Top edge → right: horizontal cut, position = right edge x.
492        steps.push(CutStep2D::Cut { axis: CutAxis::Horizontal, position: p.x + p.width });
493        num_cuts += 1;
494
495        // Right edge → bottom: vertical cut, position = bottom edge y.
496        steps.push(CutStep2D::Cut { axis: CutAxis::Vertical, position: p.y + p.height });
497        num_cuts += 1;
498
499        // Bottom edge → left: horizontal cut, position = left edge x.
500        steps.push(CutStep2D::Cut { axis: CutAxis::Horizontal, position: p.x });
501        num_cuts += 1;
502
503        // Left edge → top: vertical cut, position = top edge y.
504        steps.push(CutStep2D::Cut { axis: CutAxis::Vertical, position: p.y });
505        num_cuts += 1;
506
507        // Tool is now back at (p.x, p.y); record this for next iteration.
508        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
534/// Returns placement indices ordered by nearest-neighbor greedy traversal
535/// starting from (`start_x`, `start_y`).
536fn 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
564/// Manhattan distance between two integer coordinates, widened to `u64`.
565fn 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); // preset default
655        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    // -----------------------------------------------------------------------
692    // Task 4 tests
693    // -----------------------------------------------------------------------
694
695    #[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        // Classic pinwheel: four placements arranged so no full-width or
771        // full-height cut can avoid crossing a placement.
772        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    // -----------------------------------------------------------------------
800    // Task 5 tests
801    // -----------------------------------------------------------------------
802
803    #[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        // Classic pinwheel: four placements that cannot be guillotine-cut.
815        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        // Two side-by-side 5×5 placements: a guillotine-compatible layout.
842        // With CncRouter preset the router path must be used (not guillotine),
843        // so we expect 8 cuts (2 placements × 4 edges) and at least 1 tool-up.
844        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        // Hand-built layout where part B trails at x=25..49 — 1 unit past
880        // the layout's declared width of 48.
881        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}