Skip to main content

u_nesting_cutting/
sequence.rs

1//! Cutting sequence optimization.
2//!
3//! Determines the optimal order to cut contours, minimizing non-cutting
4//! (rapid) travel distance while respecting precedence constraints.
5//!
6//! # Algorithms
7//!
8//! 1. **Nearest Neighbor (NN)**: Greedy construction heuristic that always
9//!    selects the closest uncut contour that doesn't violate precedence.
10//! 2. **Constrained 2-opt**: Local search improvement that reverses
11//!    sub-sequences only when no precedence constraints are violated.
12//!
13//! # References
14//!
15//! - Dewil et al. (2016), Section 4: "Construction heuristics"
16
17use std::collections::{HashMap, HashSet};
18
19use crate::common_edge::CommonEdgeResult;
20use crate::config::CuttingConfig;
21use crate::contour::CutContour;
22use crate::cost::{closest_point_on_polygon, point_distance};
23use crate::hierarchy::CuttingDag;
24use crate::pierce::{select_pierce, PierceSelection};
25
26/// Result of sequence optimization.
27#[derive(Debug, Clone)]
28pub struct SequenceResult {
29    /// Contour IDs in cutting order.
30    pub order: Vec<usize>,
31    /// Pierce selections for each contour (indexed by order position).
32    pub pierce_selections: Vec<PierceSelection>,
33    /// Total rapid (non-cutting) distance.
34    pub total_rapid_distance: f64,
35}
36
37/// Optimizes the cutting sequence using Nearest Neighbor + constrained 2-opt.
38pub fn optimize_sequence(
39    contours: &[CutContour],
40    dag: &CuttingDag,
41    config: &CuttingConfig,
42) -> SequenceResult {
43    optimize_sequence_with_adjacency(contours, dag, config, None)
44}
45
46/// Optimizes the cutting sequence with optional common-edge adjacency bonus.
47///
48/// When `common_edges` is provided, the NN heuristic applies a distance
49/// discount for contours sharing edges with the previously cut contour.
50/// This encourages consecutive cutting of adjacent parts, reducing rapid
51/// travel and enabling potential common-edge single-pass cutting.
52pub fn optimize_sequence_with_adjacency(
53    contours: &[CutContour],
54    dag: &CuttingDag,
55    config: &CuttingConfig,
56    common_edges: Option<&CommonEdgeResult>,
57) -> SequenceResult {
58    if contours.is_empty() {
59        return SequenceResult {
60            order: Vec::new(),
61            pierce_selections: Vec::new(),
62            total_rapid_distance: 0.0,
63        };
64    }
65
66    // Build adjacency map from common edges
67    let adjacency = build_adjacency_map(common_edges);
68
69    // Step 1: Nearest Neighbor construction with adjacency bonus
70    let mut order = nearest_neighbor_with_adjacency(contours, dag, config, &adjacency);
71
72    // Step 2: 2-opt improvement
73    if config.max_2opt_iterations > 0 {
74        improve_2opt(&mut order, contours, dag, config);
75    }
76
77    // Step 3: Compute pierce selections for the final order
78    let (pierce_selections, total_rapid) = compute_pierce_selections(&order, contours, config);
79
80    SequenceResult {
81        order,
82        pierce_selections,
83        total_rapid_distance: total_rapid,
84    }
85}
86
87/// Builds a map of adjacent contour pairs from common edge results.
88/// Returns: contour_id -> set of adjacent contour_ids with shared edge lengths.
89fn build_adjacency_map(
90    common_edges: Option<&CommonEdgeResult>,
91) -> HashMap<usize, Vec<(usize, f64)>> {
92    let mut adjacency: HashMap<usize, Vec<(usize, f64)>> = HashMap::new();
93
94    if let Some(result) = common_edges {
95        for edge in &result.common_edges {
96            adjacency
97                .entry(edge.contour_a)
98                .or_default()
99                .push((edge.contour_b, edge.overlap_length));
100            adjacency
101                .entry(edge.contour_b)
102                .or_default()
103                .push((edge.contour_a, edge.overlap_length));
104        }
105    }
106
107    adjacency
108}
109
110/// Nearest Neighbor construction heuristic with adjacency bonus.
111///
112/// Starts from the home position and greedily selects the closest uncut
113/// contour whose prerequisites are all already cut. Contours sharing
114/// common edges with the last-cut contour receive a distance discount.
115fn nearest_neighbor_with_adjacency(
116    contours: &[CutContour],
117    dag: &CuttingDag,
118    config: &CuttingConfig,
119    adjacency: &HashMap<usize, Vec<(usize, f64)>>,
120) -> Vec<usize> {
121    let n = contours.len();
122    let mut visited: HashSet<usize> = HashSet::with_capacity(n);
123    let mut order = Vec::with_capacity(n);
124    let mut current_pos = config.home_position;
125    let mut last_id: Option<usize> = None;
126
127    // Adjacency discount factor: 0.5 means adjacent contours appear
128    // 50% closer than their actual distance.
129    const ADJACENCY_DISCOUNT: f64 = 0.5;
130
131    for _ in 0..n {
132        // Find the nearest unvisited contour whose prerequisites are satisfied
133        let mut best_idx = None;
134        let mut best_score = f64::MAX;
135
136        for contour in contours.iter() {
137            if visited.contains(&contour.id) {
138                continue;
139            }
140
141            // Check if all predecessors have been visited
142            let predecessors = dag.predecessors(contour.id);
143            let ready = predecessors.iter().all(|pred_id| visited.contains(pred_id));
144
145            if !ready {
146                continue;
147            }
148
149            // Compute distance to nearest point on this contour
150            let dist = closest_point_on_polygon(&contour.vertices, current_pos)
151                .map(|(pt, _, _)| point_distance(current_pos, pt))
152                .unwrap_or(f64::MAX);
153
154            // Apply adjacency bonus if sharing a common edge with last-cut contour
155            let mut score = dist;
156            if let Some(last) = last_id {
157                if let Some(neighbors) = adjacency.get(&last) {
158                    if neighbors.iter().any(|(adj_id, _)| *adj_id == contour.id) {
159                        score *= ADJACENCY_DISCOUNT;
160                    }
161                }
162            }
163
164            if score < best_score {
165                best_score = score;
166                best_idx = Some(contour.id);
167            }
168        }
169
170        if let Some(id) = best_idx {
171            visited.insert(id);
172            order.push(id);
173            last_id = Some(id);
174
175            // Update current position to the pierce point
176            if let Some(contour) = contours.iter().find(|c| c.id == id) {
177                let pierce = select_pierce(contour, current_pos, config);
178                current_pos = pierce.end_point;
179            }
180        }
181    }
182
183    order
184}
185
186/// Constrained 2-opt improvement.
187///
188/// Tries to reverse sub-sequences in the order to reduce total rapid distance.
189/// Only accepts reversals that don't violate precedence constraints.
190fn improve_2opt(
191    order: &mut [usize],
192    contours: &[CutContour],
193    dag: &CuttingDag,
194    config: &CuttingConfig,
195) {
196    let n = order.len();
197    if n < 3 {
198        return;
199    }
200
201    let mut improved = true;
202    let mut iterations = 0;
203    let mut current_rapid = compute_pierce_selections(order, contours, config).1;
204
205    while improved && iterations < config.max_2opt_iterations {
206        improved = false;
207        iterations += 1;
208
209        for i in 0..n - 1 {
210            for j in (i + 2)..n {
211                // Try reversing the segment [i+1..=j]
212                order[i + 1..=j].reverse();
213
214                // Check if the new sequence is valid
215                if dag.is_valid_sequence(order) {
216                    let new_rapid = compute_pierce_selections(order, contours, config).1;
217
218                    if new_rapid < current_rapid - 1e-10 {
219                        current_rapid = new_rapid;
220                        improved = true;
221                    } else {
222                        order[i + 1..=j].reverse(); // Undo — not an improvement
223                    }
224                } else {
225                    order[i + 1..=j].reverse(); // Undo — violates precedence
226                }
227            }
228        }
229    }
230}
231
232/// Computes pierce selections and total rapid distance for a given order.
233fn compute_pierce_selections(
234    order: &[usize],
235    contours: &[CutContour],
236    config: &CuttingConfig,
237) -> (Vec<PierceSelection>, f64) {
238    let mut selections = Vec::with_capacity(order.len());
239    let mut total_rapid = 0.0;
240    let mut current_pos = config.home_position;
241
242    for &contour_id in order {
243        let contour = match contours.iter().find(|c| c.id == contour_id) {
244            Some(c) => c,
245            None => continue,
246        };
247
248        let pierce = select_pierce(contour, current_pos, config);
249        let rapid = point_distance(current_pos, pierce.point);
250        total_rapid += rapid;
251        current_pos = pierce.end_point;
252        selections.push(pierce);
253    }
254
255    (selections, total_rapid)
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::contour::ContourType;
262
263    fn make_contour(id: usize, cx: f64, cy: f64, ct: ContourType) -> CutContour {
264        CutContour {
265            id,
266            geometry_id: format!("part{}", id),
267            instance: 0,
268            contour_type: ct,
269            vertices: vec![
270                (cx - 5.0, cy - 5.0),
271                (cx + 5.0, cy - 5.0),
272                (cx + 5.0, cy + 5.0),
273                (cx - 5.0, cy + 5.0),
274            ],
275            perimeter: 40.0,
276            centroid: (cx, cy),
277        }
278    }
279
280    #[test]
281    fn test_single_contour() {
282        let contours = vec![make_contour(0, 50.0, 50.0, ContourType::Exterior)];
283        let dag = CuttingDag::build(&contours);
284        let config = CuttingConfig::default();
285
286        let result = optimize_sequence(&contours, &dag, &config);
287        assert_eq!(result.order.len(), 1);
288        assert_eq!(result.order[0], 0);
289    }
290
291    #[test]
292    fn test_nn_selects_nearest() {
293        // Three parts at increasing distances from origin
294        let contours = vec![
295            make_contour(0, 100.0, 0.0, ContourType::Exterior),
296            make_contour(1, 20.0, 0.0, ContourType::Exterior),
297            make_contour(2, 60.0, 0.0, ContourType::Exterior),
298        ];
299        let dag = CuttingDag::build(&contours);
300        let config = CuttingConfig::default();
301
302        let result = optimize_sequence(&contours, &dag, &config);
303        // NN should visit: nearest first (1 at x=20), then 2 (x=60), then 0 (x=100)
304        assert_eq!(result.order, vec![1, 2, 0]);
305    }
306
307    #[test]
308    fn test_precedence_respected() {
309        // Part with interior hole — hole must come first
310        let contours = vec![
311            CutContour {
312                id: 0,
313                geometry_id: "part1".to_string(),
314                instance: 0,
315                contour_type: ContourType::Exterior,
316                vertices: vec![(0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)],
317                perimeter: 80.0,
318                centroid: (10.0, 10.0),
319            },
320            CutContour {
321                id: 1,
322                geometry_id: "part1".to_string(),
323                instance: 0,
324                contour_type: ContourType::Interior,
325                vertices: vec![(5.0, 5.0), (15.0, 5.0), (15.0, 15.0), (5.0, 15.0)],
326                perimeter: 40.0,
327                centroid: (10.0, 10.0),
328            },
329        ];
330        let dag = CuttingDag::build(&contours);
331        let config = CuttingConfig::default();
332
333        let result = optimize_sequence(&contours, &dag, &config);
334        // Interior (id=1) must come before Exterior (id=0)
335        let pos_interior = result
336            .order
337            .iter()
338            .position(|&id| id == 1)
339            .expect("interior should be in order");
340        let pos_exterior = result
341            .order
342            .iter()
343            .position(|&id| id == 0)
344            .expect("exterior should be in order");
345        assert!(pos_interior < pos_exterior);
346    }
347
348    #[test]
349    fn test_empty_contours() {
350        let contours: Vec<CutContour> = Vec::new();
351        let dag = CuttingDag::build(&contours);
352        let config = CuttingConfig::default();
353
354        let result = optimize_sequence(&contours, &dag, &config);
355        assert!(result.order.is_empty());
356        assert_eq!(result.total_rapid_distance, 0.0);
357    }
358
359    #[test]
360    fn test_nn_better_than_reverse() {
361        // Parts laid out in a line — NN should find a good order
362        let contours: Vec<CutContour> = (0..5)
363            .map(|i| make_contour(i, 20.0 * i as f64 + 10.0, 10.0, ContourType::Exterior))
364            .collect();
365        let dag = CuttingDag::build(&contours);
366        let config = CuttingConfig::default();
367
368        let result = optimize_sequence(&contours, &dag, &config);
369
370        // Compute rapid distance for reverse order
371        let reverse_order: Vec<usize> = (0..5).rev().collect();
372        let (_, reverse_rapid) = compute_pierce_selections(&reverse_order, &contours, &config);
373
374        assert!(
375            result.total_rapid_distance <= reverse_rapid + 1e-6,
376            "NN rapid {} should be <= reverse rapid {}",
377            result.total_rapid_distance,
378            reverse_rapid
379        );
380    }
381
382    #[test]
383    fn test_adjacency_bonus_prefers_neighbor() {
384        // Three contours: 0 at origin, 1 far away, 2 also far but adjacent to 0
385        // Without adjacency: NN visits 0 → 1 or 0 → 2 based on distance
386        // With adjacency (0↔2 share edge): should prefer 0 → 2 → 1
387        let contours = vec![
388            make_contour(0, 10.0, 10.0, ContourType::Exterior),
389            make_contour(1, 80.0, 10.0, ContourType::Exterior),
390            make_contour(2, 90.0, 10.0, ContourType::Exterior),
391        ];
392        let dag = CuttingDag::build(&contours);
393        let config = CuttingConfig::default();
394
395        // Create fake common edge between contour 0 and 2
396        let common_edges = CommonEdgeResult {
397            common_edges: vec![crate::common_edge::CommonEdge {
398                contour_a: 0,
399                edge_a: 0,
400                contour_b: 2,
401                edge_b: 0,
402                overlap_length: 10.0,
403                midpoint: (50.0, 10.0),
404            }],
405            total_common_length: 10.0,
406        };
407
408        let result_with =
409            optimize_sequence_with_adjacency(&contours, &dag, &config, Some(&common_edges));
410        let result_without = optimize_sequence(&contours, &dag, &config);
411
412        // Both should produce valid sequences
413        assert_eq!(result_with.order.len(), 3);
414        assert_eq!(result_without.order.len(), 3);
415
416        // With adjacency, contour 2 should be visited right after contour 0
417        // since they share an edge (adjacency discount makes it appear closer)
418        if result_with.order[0] == 0 {
419            assert_eq!(
420                result_with.order[1], 2,
421                "Adjacent contour 2 should follow contour 0"
422            );
423        }
424    }
425
426    #[test]
427    fn test_adjacency_with_no_common_edges() {
428        // No common edges — should behave identically to optimize_sequence
429        let contours = vec![
430            make_contour(0, 10.0, 10.0, ContourType::Exterior),
431            make_contour(1, 30.0, 10.0, ContourType::Exterior),
432        ];
433        let dag = CuttingDag::build(&contours);
434        let config = CuttingConfig::default();
435
436        let result_with = optimize_sequence_with_adjacency(&contours, &dag, &config, None);
437        let result_without = optimize_sequence(&contours, &dag, &config);
438
439        assert_eq!(result_with.order, result_without.order);
440    }
441}