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 u_nesting_core::timing::Timer;
20
21use crate::common_edge::CommonEdgeResult;
22use crate::config::CuttingConfig;
23use crate::contour::CutContour;
24use crate::cost::{closest_point_on_polygon, point_distance};
25use crate::hierarchy::CuttingDag;
26use crate::pierce::{select_pierce, PierceSelection};
27
28/// Result of sequence optimization.
29#[derive(Debug, Clone)]
30pub struct SequenceResult {
31    /// Contour IDs in cutting order.
32    pub order: Vec<usize>,
33    /// Pierce selections for each contour (indexed by order position).
34    pub pierce_selections: Vec<PierceSelection>,
35    /// Total rapid (non-cutting) distance.
36    pub total_rapid_distance: f64,
37}
38
39/// Optimizes the cutting sequence using Nearest Neighbor + constrained 2-opt.
40pub fn optimize_sequence(
41    contours: &[CutContour],
42    dag: &CuttingDag,
43    config: &CuttingConfig,
44) -> SequenceResult {
45    optimize_sequence_with_adjacency(contours, dag, config, None)
46}
47
48/// Optimizes the cutting sequence with optional common-edge adjacency bonus.
49///
50/// When `common_edges` is provided, the NN heuristic applies a distance
51/// discount for contours sharing edges with the previously cut contour.
52/// This encourages consecutive cutting of adjacent parts, reducing rapid
53/// travel and enabling potential common-edge single-pass cutting.
54pub fn optimize_sequence_with_adjacency(
55    contours: &[CutContour],
56    dag: &CuttingDag,
57    config: &CuttingConfig,
58    common_edges: Option<&CommonEdgeResult>,
59) -> SequenceResult {
60    if contours.is_empty() {
61        return SequenceResult {
62            order: Vec::new(),
63            pierce_selections: Vec::new(),
64            total_rapid_distance: 0.0,
65        };
66    }
67
68    // Build adjacency map from common edges
69    let adjacency = build_adjacency_map(common_edges);
70
71    // Index contours by id once: the 2-opt inner loop evaluates the tour cost
72    // O(n^2) times per pass, and a linear `contours.iter().find()` inside each
73    // evaluation would make that O(n) more expensive (the dominant cost on the
74    // reported freeze). An id -> &contour map makes each lookup O(1).
75    let index = build_contour_index(contours);
76
77    // Step 1: Nearest Neighbor construction with adjacency bonus
78    let mut order = nearest_neighbor_with_adjacency(contours, dag, config, &adjacency, &index);
79
80    // Step 2: 2-opt improvement (wall-clock bounded via config.time_limit_ms)
81    if config.max_2opt_iterations > 0 {
82        improve_2opt(&mut order, dag, config, &index);
83    }
84
85    // Step 3: Compute pierce selections for the final order
86    let (pierce_selections, total_rapid) = compute_pierce_selections(&order, &index, config);
87
88    SequenceResult {
89        order,
90        pierce_selections,
91        total_rapid_distance: total_rapid,
92    }
93}
94
95/// Builds a map of adjacent contour pairs from common edge results.
96/// Returns: contour_id -> set of adjacent contour_ids with shared edge lengths.
97fn build_adjacency_map(
98    common_edges: Option<&CommonEdgeResult>,
99) -> HashMap<usize, Vec<(usize, f64)>> {
100    let mut adjacency: HashMap<usize, Vec<(usize, f64)>> = HashMap::new();
101
102    if let Some(result) = common_edges {
103        for edge in &result.common_edges {
104            adjacency
105                .entry(edge.contour_a)
106                .or_default()
107                .push((edge.contour_b, edge.overlap_length));
108            adjacency
109                .entry(edge.contour_b)
110                .or_default()
111                .push((edge.contour_a, edge.overlap_length));
112        }
113    }
114
115    adjacency
116}
117
118/// Builds an `id -> &contour` lookup so per-move cost evaluation avoids a
119/// linear scan over `contours`.
120fn build_contour_index(contours: &[CutContour]) -> HashMap<usize, &CutContour> {
121    contours.iter().map(|c| (c.id, c)).collect()
122}
123
124/// Nearest Neighbor construction heuristic with adjacency bonus.
125///
126/// Starts from the home position and greedily selects the closest uncut
127/// contour whose prerequisites are all already cut. Contours sharing
128/// common edges with the last-cut contour receive a distance discount.
129fn nearest_neighbor_with_adjacency(
130    contours: &[CutContour],
131    dag: &CuttingDag,
132    config: &CuttingConfig,
133    adjacency: &HashMap<usize, Vec<(usize, f64)>>,
134    index: &HashMap<usize, &CutContour>,
135) -> Vec<usize> {
136    let n = contours.len();
137    let mut visited: HashSet<usize> = HashSet::with_capacity(n);
138    let mut order = Vec::with_capacity(n);
139    let mut current_pos = config.home_position;
140    let mut last_id: Option<usize> = None;
141
142    // Adjacency discount factor: 0.5 means adjacent contours appear
143    // 50% closer than their actual distance.
144    const ADJACENCY_DISCOUNT: f64 = 0.5;
145
146    for _ in 0..n {
147        // Find the nearest unvisited contour whose prerequisites are satisfied
148        let mut best_idx = None;
149        let mut best_score = f64::MAX;
150
151        for contour in contours.iter() {
152            if visited.contains(&contour.id) {
153                continue;
154            }
155
156            // Check if all predecessors have been visited
157            let predecessors = dag.predecessors(contour.id);
158            let ready = predecessors.iter().all(|pred_id| visited.contains(pred_id));
159
160            if !ready {
161                continue;
162            }
163
164            // Compute distance to nearest point on this contour
165            let dist = closest_point_on_polygon(&contour.vertices, current_pos)
166                .map(|(pt, _, _)| point_distance(current_pos, pt))
167                .unwrap_or(f64::MAX);
168
169            // Apply adjacency bonus if sharing a common edge with last-cut contour
170            let mut score = dist;
171            if let Some(last) = last_id {
172                if let Some(neighbors) = adjacency.get(&last) {
173                    if neighbors.iter().any(|(adj_id, _)| *adj_id == contour.id) {
174                        score *= ADJACENCY_DISCOUNT;
175                    }
176                }
177            }
178
179            if score < best_score {
180                best_score = score;
181                best_idx = Some(contour.id);
182            }
183        }
184
185        if let Some(id) = best_idx {
186            visited.insert(id);
187            order.push(id);
188            last_id = Some(id);
189
190            // Update current position to the pierce point
191            if let Some(contour) = index.get(&id) {
192                let pierce = select_pierce(contour, current_pos, config);
193                current_pos = pierce.end_point;
194            }
195        }
196    }
197
198    order
199}
200
201/// How many candidate moves to evaluate between wall-clock checks.
202///
203/// Reading the clock on every move would cross the JS `performance.now()`
204/// boundary `O(n^2)` times per pass on WASM. Amortizing keeps the overrun
205/// tiny (a partial `i`-sweep) while avoiding that overhead.
206const TIME_CHECK_INTERVAL: u32 = 512;
207
208/// Constrained 2-opt improvement.
209///
210/// Tries to reverse sub-sequences in the order to reduce total rapid distance.
211/// Only accepts reversals that don't violate precedence constraints.
212///
213/// Bounded by `config.time_limit_ms` (0 = unlimited): the check lives inside
214/// the innermost `for j` loop because a single `i`/`j` double-loop pass is
215/// itself the multi-second cost on large inputs — a check only at the outer
216/// `while` level would not fire until after that pass completed. On timeout we
217/// return with `order` in a valid, accepted state (every non-improving or
218/// precedence-violating reversal is undone immediately), i.e. the best
219/// sequence found so far.
220fn improve_2opt(
221    order: &mut [usize],
222    dag: &CuttingDag,
223    config: &CuttingConfig,
224    index: &HashMap<usize, &CutContour>,
225) {
226    let n = order.len();
227    if n < 3 {
228        return;
229    }
230
231    let timer = Timer::now();
232    let time_limited = config.time_limit_ms > 0;
233    let mut since_check: u32 = 0;
234
235    let mut improved = true;
236    let mut iterations = 0;
237    let mut current_rapid = compute_pierce_selections(order, index, config).1;
238
239    while improved && iterations < config.max_2opt_iterations {
240        improved = false;
241        iterations += 1;
242
243        for i in 0..n - 1 {
244            for j in (i + 2)..n {
245                // Wall-clock guard (amortized). `order` is in a clean accepted
246                // state here — before the tentative reverse below — so
247                // returning now yields a valid best-so-far sequence.
248                since_check += 1;
249                if time_limited && since_check >= TIME_CHECK_INTERVAL {
250                    since_check = 0;
251                    if timer.elapsed_ms() >= config.time_limit_ms {
252                        return;
253                    }
254                }
255
256                // Try reversing the segment [i+1..=j]
257                order[i + 1..=j].reverse();
258
259                // Check if the new sequence is valid
260                if dag.is_valid_sequence(order) {
261                    let new_rapid = compute_pierce_selections(order, index, config).1;
262
263                    if new_rapid < current_rapid - 1e-10 {
264                        current_rapid = new_rapid;
265                        improved = true;
266                    } else {
267                        order[i + 1..=j].reverse(); // Undo — not an improvement
268                    }
269                } else {
270                    order[i + 1..=j].reverse(); // Undo — violates precedence
271                }
272            }
273        }
274    }
275}
276
277/// Computes pierce selections and total rapid distance for a given order.
278///
279/// `index` maps contour id to contour, so this is `O(n)` per call rather than
280/// `O(n^2)` — critical because the 2-opt loop calls it `O(n^2)` times per pass.
281fn compute_pierce_selections(
282    order: &[usize],
283    index: &HashMap<usize, &CutContour>,
284    config: &CuttingConfig,
285) -> (Vec<PierceSelection>, f64) {
286    let mut selections = Vec::with_capacity(order.len());
287    let mut total_rapid = 0.0;
288    let mut current_pos = config.home_position;
289
290    for &contour_id in order {
291        let contour = match index.get(&contour_id) {
292            Some(c) => c,
293            None => continue,
294        };
295
296        let pierce = select_pierce(contour, current_pos, config);
297        let rapid = point_distance(current_pos, pierce.point);
298        total_rapid += rapid;
299        current_pos = pierce.end_point;
300        selections.push(pierce);
301    }
302
303    (selections, total_rapid)
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::contour::ContourType;
310
311    fn make_contour(id: usize, cx: f64, cy: f64, ct: ContourType) -> CutContour {
312        CutContour {
313            id,
314            geometry_id: format!("part{}", id),
315            instance: 0,
316            contour_type: ct,
317            vertices: vec![
318                (cx - 5.0, cy - 5.0),
319                (cx + 5.0, cy - 5.0),
320                (cx + 5.0, cy + 5.0),
321                (cx - 5.0, cy + 5.0),
322            ],
323            perimeter: 40.0,
324            centroid: (cx, cy),
325        }
326    }
327
328    #[test]
329    fn test_single_contour() {
330        let contours = vec![make_contour(0, 50.0, 50.0, ContourType::Exterior)];
331        let dag = CuttingDag::build(&contours);
332        let config = CuttingConfig::default();
333
334        let result = optimize_sequence(&contours, &dag, &config);
335        assert_eq!(result.order.len(), 1);
336        assert_eq!(result.order[0], 0);
337    }
338
339    #[test]
340    fn test_nn_selects_nearest() {
341        // Three parts at increasing distances from origin
342        let contours = vec![
343            make_contour(0, 100.0, 0.0, ContourType::Exterior),
344            make_contour(1, 20.0, 0.0, ContourType::Exterior),
345            make_contour(2, 60.0, 0.0, ContourType::Exterior),
346        ];
347        let dag = CuttingDag::build(&contours);
348        let config = CuttingConfig::default();
349
350        let result = optimize_sequence(&contours, &dag, &config);
351        // NN should visit: nearest first (1 at x=20), then 2 (x=60), then 0 (x=100)
352        assert_eq!(result.order, vec![1, 2, 0]);
353    }
354
355    #[test]
356    fn test_precedence_respected() {
357        // Part with interior hole — hole must come first
358        let contours = vec![
359            CutContour {
360                id: 0,
361                geometry_id: "part1".to_string(),
362                instance: 0,
363                contour_type: ContourType::Exterior,
364                vertices: vec![(0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)],
365                perimeter: 80.0,
366                centroid: (10.0, 10.0),
367            },
368            CutContour {
369                id: 1,
370                geometry_id: "part1".to_string(),
371                instance: 0,
372                contour_type: ContourType::Interior,
373                vertices: vec![(5.0, 5.0), (15.0, 5.0), (15.0, 15.0), (5.0, 15.0)],
374                perimeter: 40.0,
375                centroid: (10.0, 10.0),
376            },
377        ];
378        let dag = CuttingDag::build(&contours);
379        let config = CuttingConfig::default();
380
381        let result = optimize_sequence(&contours, &dag, &config);
382        // Interior (id=1) must come before Exterior (id=0)
383        let pos_interior = result
384            .order
385            .iter()
386            .position(|&id| id == 1)
387            .expect("interior should be in order");
388        let pos_exterior = result
389            .order
390            .iter()
391            .position(|&id| id == 0)
392            .expect("exterior should be in order");
393        assert!(pos_interior < pos_exterior);
394    }
395
396    #[test]
397    fn test_empty_contours() {
398        let contours: Vec<CutContour> = Vec::new();
399        let dag = CuttingDag::build(&contours);
400        let config = CuttingConfig::default();
401
402        let result = optimize_sequence(&contours, &dag, &config);
403        assert!(result.order.is_empty());
404        assert_eq!(result.total_rapid_distance, 0.0);
405    }
406
407    #[test]
408    fn test_nn_better_than_reverse() {
409        // Parts laid out in a line — NN should find a good order
410        let contours: Vec<CutContour> = (0..5)
411            .map(|i| make_contour(i, 20.0 * i as f64 + 10.0, 10.0, ContourType::Exterior))
412            .collect();
413        let dag = CuttingDag::build(&contours);
414        let config = CuttingConfig::default();
415
416        let result = optimize_sequence(&contours, &dag, &config);
417
418        // Compute rapid distance for reverse order
419        let reverse_order: Vec<usize> = (0..5).rev().collect();
420        let index = build_contour_index(&contours);
421        let (_, reverse_rapid) = compute_pierce_selections(&reverse_order, &index, &config);
422
423        assert!(
424            result.total_rapid_distance <= reverse_rapid + 1e-6,
425            "NN rapid {} should be <= reverse rapid {}",
426            result.total_rapid_distance,
427            reverse_rapid
428        );
429    }
430
431    #[test]
432    fn test_adjacency_bonus_prefers_neighbor() {
433        // Three contours: 0 at origin, 1 far away, 2 also far but adjacent to 0
434        // Without adjacency: NN visits 0 → 1 or 0 → 2 based on distance
435        // With adjacency (0↔2 share edge): should prefer 0 → 2 → 1
436        let contours = vec![
437            make_contour(0, 10.0, 10.0, ContourType::Exterior),
438            make_contour(1, 80.0, 10.0, ContourType::Exterior),
439            make_contour(2, 90.0, 10.0, ContourType::Exterior),
440        ];
441        let dag = CuttingDag::build(&contours);
442        let config = CuttingConfig::default();
443
444        // Create fake common edge between contour 0 and 2
445        let common_edges = CommonEdgeResult {
446            common_edges: vec![crate::common_edge::CommonEdge {
447                contour_a: 0,
448                edge_a: 0,
449                contour_b: 2,
450                edge_b: 0,
451                overlap_length: 10.0,
452                midpoint: (50.0, 10.0),
453            }],
454            total_common_length: 10.0,
455        };
456
457        let result_with =
458            optimize_sequence_with_adjacency(&contours, &dag, &config, Some(&common_edges));
459        let result_without = optimize_sequence(&contours, &dag, &config);
460
461        // Both should produce valid sequences
462        assert_eq!(result_with.order.len(), 3);
463        assert_eq!(result_without.order.len(), 3);
464
465        // With adjacency, contour 2 should be visited right after contour 0
466        // since they share an edge (adjacency discount makes it appear closer)
467        if result_with.order[0] == 0 {
468            assert_eq!(
469                result_with.order[1], 2,
470                "Adjacent contour 2 should follow contour 0"
471            );
472        }
473    }
474
475    #[test]
476    fn test_2opt_respects_time_limit() {
477        // Regression: cut-path 2-opt must be wall-clock bounded so it cannot
478        // freeze the (browser main) thread on large, legitimate inputs
479        // ("N identical brackets on a sheet"). See
480        // ISSUE-20260707-unesting-optimize-cutting-path-unbounded-runtime.
481        //
482        // 1500 distinct squares keep `improved == true` across passes, so
483        // without a time bound the O(n^4)-per-pass 2-opt runs for many
484        // seconds/minutes. With a 100ms budget it must return promptly with a
485        // valid, complete order.
486        let contours: Vec<CutContour> = (0..1500)
487            .map(|i| {
488                let cx = (i % 40) as f64 * 20.0;
489                let cy = (i / 40) as f64 * 20.0;
490                make_contour(i, cx, cy, ContourType::Exterior)
491            })
492            .collect();
493        let dag = CuttingDag::build(&contours);
494        let config = CuttingConfig::default()
495            .with_max_2opt_iterations(1000)
496            .with_time_limit_ms(100);
497
498        let start = std::time::Instant::now();
499        let result = optimize_sequence(&contours, &dag, &config);
500        let elapsed = start.elapsed();
501
502        // Early termination must still yield a valid, complete sequence.
503        assert_eq!(result.order.len(), 1500);
504        // Generous ceiling: the unbounded path took >5.6s at n=500 and is far
505        // worse at n=1500. 5s never flakes yet catches an unbounded regression.
506        assert!(
507            elapsed.as_secs() < 5,
508            "2-opt must respect time_limit_ms; took {elapsed:?}"
509        );
510    }
511
512    #[test]
513    fn test_zero_time_limit_is_unlimited() {
514        // time_limit_ms == 0 preserves the anytime/iteration-cap semantics
515        // (unbounded by wall clock) for native batch callers.
516        let contours: Vec<CutContour> = (0..5)
517            .map(|i| make_contour(i, 20.0 * i as f64 + 10.0, 10.0, ContourType::Exterior))
518            .collect();
519        let dag = CuttingDag::build(&contours);
520        let config = CuttingConfig::default().with_time_limit_ms(0);
521
522        let result = optimize_sequence(&contours, &dag, &config);
523        assert_eq!(result.order.len(), 5);
524    }
525
526    #[test]
527    fn test_adjacency_with_no_common_edges() {
528        // No common edges — should behave identically to optimize_sequence
529        let contours = vec![
530            make_contour(0, 10.0, 10.0, ContourType::Exterior),
531            make_contour(1, 30.0, 10.0, ContourType::Exterior),
532        ];
533        let dag = CuttingDag::build(&contours);
534        let config = CuttingConfig::default();
535
536        let result_with = optimize_sequence_with_adjacency(&contours, &dag, &config, None);
537        let result_without = optimize_sequence(&contours, &dag, &config);
538
539        assert_eq!(result_with.order, result_without.order);
540    }
541}