Skip to main content

u_nesting_cutting/
gtsp.rs

1//! Generalized TSP (GTSP) formulation for cutting path optimization.
2//!
3//! Models the cutting sequence problem as a GTSP where:
4//! - Each contour is a **cluster** with multiple candidate pierce points
5//! - The goal is to select exactly one candidate per cluster and find the
6//!   shortest tour visiting all clusters
7//!
8//! # Algorithm
9//!
10//! 1. **Discretize**: Generate N equidistant pierce candidates per contour
11//! 2. **Distance matrix**: Compute asymmetric rapid-traverse distances
12//!    between all candidate pairs across different clusters
13//! 3. **Solve**: Use Noon-Bean transformation to ATSP, then apply
14//!    metaheuristic solver (Cycle 46)
15//!
16//! # References
17//!
18//! - Noon & Bean (1993), "An efficient transformation of the GTSP"
19//! - Dewil et al. (2015), "An improvement heuristic framework for the laser
20//!   cutting tool path problem"
21
22use crate::config::{CutDirectionPreference, CuttingConfig};
23use crate::contour::{ContourType, CutContour};
24use crate::cost::point_distance;
25use crate::result::CutDirection;
26
27/// A candidate pierce point on a contour.
28#[derive(Debug, Clone)]
29pub struct PierceCandidate {
30    /// Contour this candidate belongs to.
31    pub contour_id: usize,
32    /// Index within the cluster's candidate list.
33    pub candidate_index: usize,
34    /// The pierce point coordinates.
35    pub point: (f64, f64),
36    /// The nearest vertex index on the contour.
37    pub vertex_index: usize,
38    /// Cut direction for this contour.
39    pub direction: CutDirection,
40    /// End point after cutting the full contour (same as `point` for closed contours).
41    pub end_point: (f64, f64),
42}
43
44/// A GTSP cluster: one contour with its candidate pierce points.
45#[derive(Debug, Clone)]
46pub struct GtspCluster {
47    /// ID of the contour this cluster represents.
48    pub contour_id: usize,
49    /// Candidate pierce points for this contour.
50    pub candidates: Vec<PierceCandidate>,
51}
52
53/// A complete GTSP instance with distance matrix.
54#[derive(Debug, Clone)]
55pub struct GtspInstance {
56    /// Clusters (one per contour).
57    pub clusters: Vec<GtspCluster>,
58    /// Asymmetric distance matrix between all candidates.
59    /// `distances[i][j]` = rapid distance from end_point of global candidate `i`
60    /// to pierce point of global candidate `j`.
61    /// Intra-cluster distances are set to `f64::MAX` (invalid transitions).
62    pub distances: Vec<Vec<f64>>,
63    /// Distance from home position to each candidate's pierce point.
64    pub home_distances: Vec<f64>,
65    /// Cumulative offset for each cluster in the global index.
66    pub cluster_offsets: Vec<usize>,
67    /// Total number of candidates across all clusters.
68    pub total_candidates: usize,
69}
70
71impl GtspInstance {
72    /// Returns the cluster index and local candidate index for a global index.
73    pub fn global_to_local(&self, global_idx: usize) -> (usize, usize) {
74        for (c, offset) in self.cluster_offsets.iter().enumerate() {
75            let size = self.clusters[c].candidates.len();
76            if global_idx >= *offset && global_idx < offset + size {
77                return (c, global_idx - offset);
78            }
79        }
80        // Should never happen if global_idx is valid
81        (self.clusters.len() - 1, 0)
82    }
83
84    /// Returns the global index for a cluster and local candidate index.
85    pub fn local_to_global(&self, cluster_idx: usize, candidate_idx: usize) -> usize {
86        self.cluster_offsets[cluster_idx] + candidate_idx
87    }
88
89    /// Returns the candidate at a global index.
90    pub fn candidate(&self, global_idx: usize) -> &PierceCandidate {
91        let (c, l) = self.global_to_local(global_idx);
92        &self.clusters[c].candidates[l]
93    }
94}
95
96/// Discretizes contours into GTSP clusters with equidistant pierce candidates.
97///
98/// Each contour gets `config.pierce_candidates` equidistant points along its
99/// perimeter. If `pierce_candidates == 1`, uses the centroid-facing vertex.
100///
101/// # Arguments
102///
103/// * `contours` - Cut contours to discretize
104/// * `config` - Cutting configuration (pierce_candidates, direction preferences)
105pub fn discretize_contours(contours: &[CutContour], config: &CuttingConfig) -> Vec<GtspCluster> {
106    let n_candidates = config.pierce_candidates.max(1);
107
108    contours
109        .iter()
110        .map(|contour| {
111            let direction = determine_direction(contour.contour_type, config);
112            let candidates = if n_candidates == 1 {
113                // Single candidate: use first vertex
114                vec![PierceCandidate {
115                    contour_id: contour.id,
116                    candidate_index: 0,
117                    point: contour.vertices[0],
118                    vertex_index: 0,
119                    direction,
120                    end_point: contour.vertices[0],
121                }]
122            } else {
123                generate_equidistant_candidates(contour, n_candidates, direction)
124            };
125
126            GtspCluster {
127                contour_id: contour.id,
128                candidates,
129            }
130        })
131        .collect()
132}
133
134/// Builds a GTSP instance with asymmetric distance matrix.
135///
136/// The distance matrix is indexed by global candidate indices. Intra-cluster
137/// distances are set to `f64::MAX` since we must visit exactly one candidate
138/// per cluster.
139///
140/// # Arguments
141///
142/// * `clusters` - GTSP clusters from `discretize_contours`
143/// * `home` - Home/start position for the cutting head
144pub fn build_gtsp_instance(clusters: Vec<GtspCluster>, home: (f64, f64)) -> GtspInstance {
145    // Compute cluster offsets
146    let mut cluster_offsets = Vec::with_capacity(clusters.len());
147    let mut offset = 0;
148    for cluster in &clusters {
149        cluster_offsets.push(offset);
150        offset += cluster.candidates.len();
151    }
152    let total = offset;
153
154    // Collect all candidates for distance computation
155    let all_candidates: Vec<&PierceCandidate> =
156        clusters.iter().flat_map(|c| c.candidates.iter()).collect();
157
158    // Build distance matrix
159    let mut distances = vec![vec![f64::MAX; total]; total];
160    for (i, ci) in all_candidates.iter().enumerate() {
161        for (j, cj) in all_candidates.iter().enumerate() {
162            if ci.contour_id == cj.contour_id {
163                continue; // Intra-cluster: keep as MAX
164            }
165            distances[i][j] = point_distance(ci.end_point, cj.point);
166        }
167    }
168
169    // Home distances
170    let home_distances: Vec<f64> = all_candidates
171        .iter()
172        .map(|c| point_distance(home, c.point))
173        .collect();
174
175    GtspInstance {
176        clusters,
177        distances,
178        home_distances,
179        cluster_offsets,
180        total_candidates: total,
181    }
182}
183
184/// Evaluates the total rapid distance for a GTSP solution.
185///
186/// A solution is a list of global candidate indices, one per cluster, in visit order.
187pub fn evaluate_solution(instance: &GtspInstance, solution: &[usize]) -> f64 {
188    if solution.is_empty() {
189        return 0.0;
190    }
191
192    let mut total = instance.home_distances[solution[0]];
193
194    for i in 1..solution.len() {
195        total += instance.distances[solution[i - 1]][solution[i]];
196    }
197
198    total
199}
200
201/// Solves the GTSP using a greedy nearest-neighbor heuristic.
202///
203/// For each step, selects the nearest candidate from any unvisited cluster.
204/// Returns the global candidate indices in visit order.
205pub fn solve_nn(instance: &GtspInstance) -> Vec<usize> {
206    let n_clusters = instance.clusters.len();
207    if n_clusters == 0 {
208        return Vec::new();
209    }
210
211    let mut visited_clusters = vec![false; n_clusters];
212    let mut solution = Vec::with_capacity(n_clusters);
213
214    // First: find nearest candidate from home
215    let mut best_idx = 0;
216    let mut best_dist = f64::MAX;
217    for (g, dist) in instance.home_distances.iter().enumerate() {
218        if *dist < best_dist {
219            best_dist = *dist;
220            best_idx = g;
221        }
222    }
223
224    let (cluster, _) = instance.global_to_local(best_idx);
225    visited_clusters[cluster] = true;
226    solution.push(best_idx);
227
228    // Greedily add nearest unvisited cluster's candidate
229    for _ in 1..n_clusters {
230        let last = *solution.last().expect("solution not empty");
231        let mut next_best = 0;
232        let mut next_dist = f64::MAX;
233
234        for (g, &dist) in instance.distances[last].iter().enumerate() {
235            let (c, _) = instance.global_to_local(g);
236            if visited_clusters[c] {
237                continue;
238            }
239            if dist < next_dist {
240                next_dist = dist;
241                next_best = g;
242            }
243        }
244
245        let (c, _) = instance.global_to_local(next_best);
246        visited_clusters[c] = true;
247        solution.push(next_best);
248    }
249
250    solution
251}
252
253/// Solves the GTSP with precedence constraints using NN + constrained 2-opt.
254///
255/// This is the main solver that respects the precedence DAG. It:
256/// 1. Builds a precedence-aware NN solution (only visiting clusters whose
257///    predecessors have already been visited)
258/// 2. Improves with 2-opt swaps that maintain precedence validity
259///
260/// Returns the global candidate indices in visit order.
261pub fn solve_constrained(
262    instance: &GtspInstance,
263    dag: &crate::hierarchy::CuttingDag,
264    max_2opt_iterations: usize,
265) -> Vec<usize> {
266    let n_clusters = instance.clusters.len();
267    if n_clusters == 0 {
268        return Vec::new();
269    }
270
271    // Step 1: Precedence-aware NN
272    let mut solution = nn_constrained(instance, dag);
273
274    // Step 2: Constrained 2-opt
275    if max_2opt_iterations > 0 && solution.len() >= 3 {
276        improve_2opt_constrained(&mut solution, instance, dag, max_2opt_iterations);
277    }
278
279    solution
280}
281
282/// Nearest-neighbor construction that respects precedence constraints.
283fn nn_constrained(instance: &GtspInstance, dag: &crate::hierarchy::CuttingDag) -> Vec<usize> {
284    let n_clusters = instance.clusters.len();
285    let mut visited_clusters = vec![false; n_clusters];
286    let mut solution = Vec::with_capacity(n_clusters);
287    let mut visited_contours: std::collections::HashSet<usize> =
288        std::collections::HashSet::with_capacity(n_clusters);
289
290    for _ in 0..n_clusters {
291        let mut best_idx = None;
292        let mut best_dist = f64::MAX;
293
294        for (ci, cluster) in instance.clusters.iter().enumerate() {
295            if visited_clusters[ci] {
296                continue;
297            }
298
299            // Check precedence: all predecessors' clusters must be visited
300            let predecessors = dag.predecessors(cluster.contour_id);
301            let ready = predecessors
302                .iter()
303                .all(|pred_id| visited_contours.contains(pred_id));
304            if !ready {
305                continue;
306            }
307
308            // Find the best candidate in this cluster
309            for cand in &cluster.candidates {
310                let global = instance.local_to_global(ci, cand.candidate_index);
311                let dist = if solution.is_empty() {
312                    instance.home_distances[global]
313                } else {
314                    let last: usize = *solution.last().expect("solution not empty");
315                    let row: &Vec<f64> = &instance.distances[last];
316                    row[global]
317                };
318
319                if dist < best_dist {
320                    best_dist = dist;
321                    best_idx = Some((ci, global));
322                }
323            }
324        }
325
326        if let Some((ci, global)) = best_idx {
327            visited_clusters[ci] = true;
328            visited_contours.insert(instance.clusters[ci].contour_id);
329            solution.push(global);
330        }
331    }
332
333    solution
334}
335
336/// Constrained 2-opt improvement for GTSP solutions.
337///
338/// Tries swapping candidates within clusters and reversing sub-sequences,
339/// accepting only moves that reduce cost and respect precedence.
340fn improve_2opt_constrained(
341    solution: &mut [usize],
342    instance: &GtspInstance,
343    dag: &crate::hierarchy::CuttingDag,
344    max_iterations: usize,
345) {
346    let n = solution.len();
347    let mut improved = true;
348    let mut iterations = 0;
349    let mut current_cost = evaluate_solution(instance, solution);
350
351    while improved && iterations < max_iterations {
352        improved = false;
353        iterations += 1;
354
355        // Move 1: Try swapping each position to a better candidate in the same cluster
356        for pos in 0..n {
357            let current_global = solution[pos];
358            let (ci, _) = instance.global_to_local(current_global);
359            let cluster = &instance.clusters[ci];
360
361            for cand in &cluster.candidates {
362                let alt_global = instance.local_to_global(ci, cand.candidate_index);
363                if alt_global == current_global {
364                    continue;
365                }
366
367                solution[pos] = alt_global;
368                let new_cost = evaluate_solution(instance, solution);
369
370                if new_cost < current_cost - 1e-10 {
371                    current_cost = new_cost;
372                    improved = true;
373                } else {
374                    solution[pos] = current_global; // Undo
375                }
376            }
377        }
378
379        // Move 2: Try reversing sub-sequences (cluster-order level)
380        for i in 0..n.saturating_sub(1) {
381            for j in (i + 2)..n {
382                solution[i + 1..=j].reverse();
383
384                // Check precedence validity
385                let cluster_order: Vec<usize> = solution
386                    .iter()
387                    .map(|&g| instance.clusters[instance.global_to_local(g).0].contour_id)
388                    .collect();
389
390                if dag.is_valid_sequence(&cluster_order) {
391                    let new_cost = evaluate_solution(instance, solution);
392                    if new_cost < current_cost - 1e-10 {
393                        current_cost = new_cost;
394                        improved = true;
395                    } else {
396                        solution[i + 1..=j].reverse(); // Undo
397                    }
398                } else {
399                    solution[i + 1..=j].reverse(); // Undo — violates precedence
400                }
401            }
402        }
403    }
404}
405
406/// Determines the cutting direction for a contour type.
407fn determine_direction(contour_type: ContourType, config: &CuttingConfig) -> CutDirection {
408    let pref = match contour_type {
409        ContourType::Exterior => config.exterior_direction,
410        ContourType::Interior => config.interior_direction,
411    };
412
413    match pref {
414        CutDirectionPreference::Ccw => CutDirection::Ccw,
415        CutDirectionPreference::Cw => CutDirection::Cw,
416        CutDirectionPreference::Auto => match contour_type {
417            ContourType::Exterior => CutDirection::Ccw,
418            ContourType::Interior => CutDirection::Cw,
419        },
420    }
421}
422
423/// Generates equidistant pierce candidates along a contour's perimeter.
424fn generate_equidistant_candidates(
425    contour: &CutContour,
426    n: usize,
427    direction: CutDirection,
428) -> Vec<PierceCandidate> {
429    let vertices = &contour.vertices;
430    let nv = vertices.len();
431    if nv == 0 {
432        return Vec::new();
433    }
434
435    // Compute cumulative edge lengths
436    let mut edge_lengths = Vec::with_capacity(nv);
437    let mut cumulative = Vec::with_capacity(nv + 1);
438    cumulative.push(0.0);
439
440    for i in 0..nv {
441        let j = (i + 1) % nv;
442        let len = point_distance(vertices[i], vertices[j]);
443        edge_lengths.push(len);
444        cumulative.push(cumulative[i] + len);
445    }
446
447    let perimeter = *cumulative.last().expect("at least one vertex");
448    if perimeter < 1e-12 {
449        return vec![PierceCandidate {
450            contour_id: contour.id,
451            candidate_index: 0,
452            point: vertices[0],
453            vertex_index: 0,
454            direction,
455            end_point: vertices[0],
456        }];
457    }
458
459    let spacing = perimeter / n as f64;
460    let mut candidates = Vec::with_capacity(n);
461
462    for k in 0..n {
463        let target_dist = k as f64 * spacing;
464
465        // Find which edge this distance falls on
466        let (point, vertex_idx) =
467            point_at_distance(vertices, &cumulative, &edge_lengths, target_dist);
468
469        candidates.push(PierceCandidate {
470            contour_id: contour.id,
471            candidate_index: k,
472            point,
473            vertex_index: vertex_idx,
474            direction,
475            end_point: point, // Closed contour: returns to pierce point
476        });
477    }
478
479    candidates
480}
481
482/// Finds the point at a given distance along the contour perimeter.
483fn point_at_distance(
484    vertices: &[(f64, f64)],
485    cumulative: &[f64],
486    edge_lengths: &[f64],
487    distance: f64,
488) -> ((f64, f64), usize) {
489    let nv = vertices.len();
490    let perimeter = cumulative[nv];
491    let dist = distance % perimeter;
492
493    for i in 0..nv {
494        if dist >= cumulative[i] && dist <= cumulative[i + 1] + 1e-12 {
495            let edge_len = edge_lengths[i];
496            if edge_len < 1e-12 {
497                return (vertices[i], i);
498            }
499            let t = (dist - cumulative[i]) / edge_len;
500            let j = (i + 1) % nv;
501            let px = vertices[i].0 + t * (vertices[j].0 - vertices[i].0);
502            let py = vertices[i].1 + t * (vertices[j].1 - vertices[i].1);
503            return ((px, py), i);
504        }
505    }
506
507    // Fallback: last vertex
508    (vertices[nv - 1], nv - 1)
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    fn make_rect(id: usize, x: f64, y: f64, w: f64, h: f64) -> CutContour {
516        CutContour {
517            id,
518            geometry_id: format!("part{}", id),
519            instance: 0,
520            contour_type: ContourType::Exterior,
521            vertices: vec![(x, y), (x + w, y), (x + w, y + h), (x, y + h)],
522            perimeter: 2.0 * (w + h),
523            centroid: (x + w / 2.0, y + h / 2.0),
524        }
525    }
526
527    #[test]
528    fn test_discretize_single_candidate() {
529        let contours = vec![make_rect(0, 0.0, 0.0, 10.0, 10.0)];
530        let config = CuttingConfig::new().with_pierce_candidates(1);
531        let clusters = discretize_contours(&contours, &config);
532
533        assert_eq!(clusters.len(), 1);
534        assert_eq!(clusters[0].candidates.len(), 1);
535        assert_eq!(clusters[0].candidates[0].point, (0.0, 0.0));
536    }
537
538    #[test]
539    fn test_discretize_four_candidates_on_square() {
540        let contours = vec![make_rect(0, 0.0, 0.0, 10.0, 10.0)];
541        let config = CuttingConfig::new().with_pierce_candidates(4);
542        let clusters = discretize_contours(&contours, &config);
543
544        assert_eq!(clusters.len(), 1);
545        let cands = &clusters[0].candidates;
546        assert_eq!(cands.len(), 4);
547
548        // Perimeter = 40, spacing = 10 → points at distances 0, 10, 20, 30
549        // Vertices: (0,0), (10,0), (10,10), (0,10)
550        // dist 0 → (0,0), dist 10 → (10,0), dist 20 → (10,10), dist 30 → (0,10)
551        assert!((cands[0].point.0 - 0.0).abs() < 1e-10);
552        assert!((cands[0].point.1 - 0.0).abs() < 1e-10);
553        assert!((cands[1].point.0 - 10.0).abs() < 1e-10);
554        assert!((cands[1].point.1 - 0.0).abs() < 1e-10);
555        assert!((cands[2].point.0 - 10.0).abs() < 1e-10);
556        assert!((cands[2].point.1 - 10.0).abs() < 1e-10);
557        assert!((cands[3].point.0 - 0.0).abs() < 1e-10);
558        assert!((cands[3].point.1 - 10.0).abs() < 1e-10);
559    }
560
561    #[test]
562    fn test_discretize_eight_candidates_midpoints() {
563        let contours = vec![make_rect(0, 0.0, 0.0, 10.0, 10.0)];
564        let config = CuttingConfig::new().with_pierce_candidates(8);
565        let clusters = discretize_contours(&contours, &config);
566
567        let cands = &clusters[0].candidates;
568        assert_eq!(cands.len(), 8);
569
570        // Perimeter = 40, spacing = 5 → candidates at 0, 5, 10, 15, 20, 25, 30, 35
571        // dist 0 → (0,0), dist 5 → (5,0), dist 10 → (10,0), dist 15 → (10,5)
572        assert!((cands[1].point.0 - 5.0).abs() < 1e-10);
573        assert!((cands[1].point.1 - 0.0).abs() < 1e-10);
574        assert!((cands[3].point.0 - 10.0).abs() < 1e-10);
575        assert!((cands[3].point.1 - 5.0).abs() < 1e-10);
576    }
577
578    #[test]
579    fn test_build_gtsp_instance_distances() {
580        let contours = vec![
581            make_rect(0, 0.0, 0.0, 10.0, 10.0),
582            make_rect(1, 20.0, 0.0, 10.0, 10.0),
583        ];
584        let config = CuttingConfig::new().with_pierce_candidates(1);
585        let clusters = discretize_contours(&contours, &config);
586        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
587
588        assert_eq!(instance.total_candidates, 2);
589        assert_eq!(instance.clusters.len(), 2);
590
591        // Intra-cluster distances should be MAX
592        assert_eq!(instance.distances[0][0], f64::MAX);
593        assert_eq!(instance.distances[1][1], f64::MAX);
594
595        // Inter-cluster distance: (0,0) → (20,0) = 20.0
596        assert!((instance.distances[0][1] - 20.0).abs() < 1e-10);
597        // Inter-cluster distance: (20,0) → (0,0) = 20.0
598        assert!((instance.distances[1][0] - 20.0).abs() < 1e-10);
599
600        // Home distances
601        assert!((instance.home_distances[0] - 0.0).abs() < 1e-10);
602        assert!((instance.home_distances[1] - 20.0).abs() < 1e-10);
603    }
604
605    #[test]
606    fn test_global_to_local() {
607        let contours = vec![
608            make_rect(0, 0.0, 0.0, 10.0, 10.0),
609            make_rect(1, 20.0, 0.0, 10.0, 10.0),
610        ];
611        let config = CuttingConfig::new().with_pierce_candidates(4);
612        let clusters = discretize_contours(&contours, &config);
613        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
614
615        assert_eq!(instance.total_candidates, 8);
616        assert_eq!(instance.global_to_local(0), (0, 0));
617        assert_eq!(instance.global_to_local(3), (0, 3));
618        assert_eq!(instance.global_to_local(4), (1, 0));
619        assert_eq!(instance.global_to_local(7), (1, 3));
620    }
621
622    #[test]
623    fn test_evaluate_solution() {
624        let contours = vec![
625            make_rect(0, 0.0, 0.0, 10.0, 10.0),
626            make_rect(1, 30.0, 0.0, 10.0, 10.0),
627        ];
628        let config = CuttingConfig::new().with_pierce_candidates(1);
629        let clusters = discretize_contours(&contours, &config);
630        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
631
632        // Solution: visit cluster 0 first (candidate 0), then cluster 1 (candidate 1)
633        let cost = evaluate_solution(&instance, &[0, 1]);
634        // Home (0,0) → candidate 0 at (0,0) = 0.0
635        // candidate 0 end (0,0) → candidate 1 at (30,0) = 30.0
636        assert!((cost - 30.0).abs() < 1e-10);
637    }
638
639    #[test]
640    fn test_solve_nn() {
641        let contours = vec![
642            make_rect(0, 50.0, 0.0, 10.0, 10.0),
643            make_rect(1, 10.0, 0.0, 10.0, 10.0),
644            make_rect(2, 30.0, 0.0, 10.0, 10.0),
645        ];
646        let config = CuttingConfig::new().with_pierce_candidates(1);
647        let clusters = discretize_contours(&contours, &config);
648        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
649
650        let solution = solve_nn(&instance);
651        assert_eq!(solution.len(), 3);
652
653        // NN from (0,0): nearest is contour 1 at (10,0), then 2 at (30,0), then 0 at (50,0)
654        let cluster_order: Vec<usize> = solution
655            .iter()
656            .map(|&g| instance.global_to_local(g).0)
657            .collect();
658        assert_eq!(cluster_order, vec![1, 2, 0]);
659    }
660
661    #[test]
662    fn test_solve_nn_empty() {
663        let instance = build_gtsp_instance(Vec::new(), (0.0, 0.0));
664        let solution = solve_nn(&instance);
665        assert!(solution.is_empty());
666    }
667
668    #[test]
669    fn test_nn_picks_best_candidate() {
670        // Two contours, each with 2 candidates
671        // Contour 0 at (0,0)-(10,10): candidates at (0,0) and (10,0)
672        // Contour 1 at (12,0)-(22,10): candidates at (12,0) and (22,0)
673        // From home (0,0), NN should pick candidate (0,0) of contour 0
674        // Then from (0,0), should pick candidate (12,0) of contour 1
675        let contours = vec![
676            make_rect(0, 0.0, 0.0, 10.0, 10.0),
677            make_rect(1, 12.0, 0.0, 10.0, 10.0),
678        ];
679        let config = CuttingConfig::new().with_pierce_candidates(4);
680        let clusters = discretize_contours(&contours, &config);
681        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
682
683        let solution = solve_nn(&instance);
684        assert_eq!(solution.len(), 2);
685
686        // First should be from cluster 0 (nearest to home)
687        let (c0, _) = instance.global_to_local(solution[0]);
688        assert_eq!(c0, 0);
689
690        // Second should be from cluster 1 — pick the nearest candidate
691        let (c1, l1) = instance.global_to_local(solution[1]);
692        assert_eq!(c1, 1);
693        // Candidate at (12,0) should be picked (nearest to end of cluster 0)
694        let picked = &instance.clusters[1].candidates[l1];
695        assert!((picked.point.0 - 12.0).abs() < 1e-10);
696    }
697
698    #[test]
699    fn test_direction_assignment() {
700        let mut contours = vec![make_rect(0, 0.0, 0.0, 10.0, 10.0)];
701        contours[0].contour_type = ContourType::Interior;
702
703        let config = CuttingConfig::default();
704        let clusters = discretize_contours(&contours, &config);
705
706        // Interior with Auto should be CW
707        assert_eq!(clusters[0].candidates[0].direction, CutDirection::Cw);
708    }
709
710    #[test]
711    fn test_multi_candidate_improves_over_single() {
712        // Three rectangles laid out with offset — multi-candidate should find
713        // better pierce points than single-candidate
714        let contours = vec![
715            make_rect(0, 0.0, 0.0, 10.0, 10.0),
716            make_rect(1, 15.0, 5.0, 10.0, 10.0),
717            make_rect(2, 30.0, 0.0, 10.0, 10.0),
718        ];
719
720        // Single candidate
721        let config1 = CuttingConfig::new().with_pierce_candidates(1);
722        let clusters1 = discretize_contours(&contours, &config1);
723        let inst1 = build_gtsp_instance(clusters1, (0.0, 0.0));
724        let sol1 = solve_nn(&inst1);
725        let cost1 = evaluate_solution(&inst1, &sol1);
726
727        // 8 candidates per contour
728        let config8 = CuttingConfig::new().with_pierce_candidates(8);
729        let clusters8 = discretize_contours(&contours, &config8);
730        let inst8 = build_gtsp_instance(clusters8, (0.0, 0.0));
731        let sol8 = solve_nn(&inst8);
732        let cost8 = evaluate_solution(&inst8, &sol8);
733
734        // More candidates should give same or better solution
735        assert!(
736            cost8 <= cost1 + 1e-6,
737            "Multi-candidate cost {} should be <= single-candidate cost {}",
738            cost8,
739            cost1
740        );
741    }
742
743    #[test]
744    fn test_solve_constrained_respects_precedence() {
745        use crate::hierarchy::CuttingDag;
746
747        // Part with interior hole — hole must be cut first
748        let contours = vec![
749            CutContour {
750                id: 0,
751                geometry_id: "part1".to_string(),
752                instance: 0,
753                contour_type: ContourType::Exterior,
754                vertices: vec![(0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)],
755                perimeter: 80.0,
756                centroid: (10.0, 10.0),
757            },
758            CutContour {
759                id: 1,
760                geometry_id: "part1".to_string(),
761                instance: 0,
762                contour_type: ContourType::Interior,
763                vertices: vec![(5.0, 5.0), (15.0, 5.0), (15.0, 15.0), (5.0, 15.0)],
764                perimeter: 40.0,
765                centroid: (10.0, 10.0),
766            },
767        ];
768
769        let dag = CuttingDag::build(&contours);
770        let config = CuttingConfig::new().with_pierce_candidates(4);
771        let clusters = discretize_contours(&contours, &config);
772        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
773
774        let solution = solve_constrained(&instance, &dag, 100);
775        assert_eq!(solution.len(), 2);
776
777        // Interior (cluster for contour 1) must come before Exterior (cluster for contour 0)
778        let cluster_order: Vec<usize> = solution
779            .iter()
780            .map(|&g| instance.clusters[instance.global_to_local(g).0].contour_id)
781            .collect();
782
783        let pos_interior = cluster_order.iter().position(|&id| id == 1).unwrap();
784        let pos_exterior = cluster_order.iter().position(|&id| id == 0).unwrap();
785        assert!(pos_interior < pos_exterior);
786    }
787
788    #[test]
789    fn test_solve_constrained_with_multiple_parts() {
790        use crate::hierarchy::CuttingDag;
791
792        let contours = vec![
793            make_rect(0, 0.0, 0.0, 10.0, 10.0),
794            make_rect(1, 15.0, 0.0, 10.0, 10.0),
795            make_rect(2, 30.0, 0.0, 10.0, 10.0),
796        ];
797
798        let dag = CuttingDag::build(&contours);
799        let config = CuttingConfig::new().with_pierce_candidates(4);
800        let clusters = discretize_contours(&contours, &config);
801        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
802
803        let solution = solve_constrained(&instance, &dag, 100);
804        assert_eq!(solution.len(), 3);
805
806        // NN should visit in order: 0 (nearest), 1, 2
807        let cluster_order: Vec<usize> = solution
808            .iter()
809            .map(|&g| instance.global_to_local(g).0)
810            .collect();
811        assert_eq!(cluster_order, vec![0, 1, 2]);
812    }
813
814    #[test]
815    fn test_2opt_improves_solution() {
816        use crate::hierarchy::CuttingDag;
817
818        // Place contours in a way that NN gives a suboptimal order
819        // Contours arranged: home(0,0) → C1(5,20) → C0(5,0) → C2(5,40)
820        // NN from home: picks C0 (nearest), then C1, then C2
821        // Optimal: C0, C2, C1 might be worse... Let's use a zigzag
822        let contours = vec![
823            make_rect(0, 0.0, 0.0, 10.0, 10.0),
824            make_rect(1, 20.0, 0.0, 10.0, 10.0),
825            make_rect(2, 40.0, 0.0, 10.0, 10.0),
826        ];
827
828        let dag = CuttingDag::build(&contours);
829        let config = CuttingConfig::new().with_pierce_candidates(4);
830        let clusters = discretize_contours(&contours, &config);
831        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
832
833        let solution = solve_constrained(&instance, &dag, 100);
834        let cost = evaluate_solution(&instance, &solution);
835
836        // Cost should be reasonable (not worse than worst case)
837        // Worst case: home→C2(40,0)→C0(0,0)→C1(20,0) = 40+40+20 = 100
838        assert!(
839            cost < 100.0,
840            "Solution cost {} should be < worst case 100",
841            cost
842        );
843    }
844
845    #[test]
846    fn test_constrained_empty() {
847        use crate::hierarchy::CuttingDag;
848
849        let contours: Vec<CutContour> = Vec::new();
850        let dag = CuttingDag::build(&contours);
851        let instance = build_gtsp_instance(Vec::new(), (0.0, 0.0));
852
853        let solution = solve_constrained(&instance, &dag, 100);
854        assert!(solution.is_empty());
855    }
856}